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 70593b2f83 auto layout Sugiyama options. fixes #7345 (#7348)
70593b2f83 is described below

commit 70593b2f833f7a97457139b777dbeb9c2a3c2931
Author: Bart Maertens <[email protected]>
AuthorDate: Thu Jun 25 13:46:46 2026 +0200

    auto layout Sugiyama options. fixes #7345 (#7348)
---
 .../ROOT/pages/pipeline/hop-pipeline-editor.adoc   |   2 +
 .../apache/hop/core/layout/LayeredGraphLayout.java | 472 +++++++++++++++++++++
 .../apache/hop/pipeline/PipelineMetaLayout.java    | 192 +++++++++
 .../apache/hop/workflow/WorkflowMetaLayout.java    | 192 +++++++++
 .../hop/pipeline/PipelineMetaLayoutTest.java       | 258 +++++++++++
 .../hop/workflow/WorkflowMetaLayoutTest.java       | 147 +++++++
 .../main/java/org/apache/hop/ui/core/PropsUi.java  |  73 ++++
 .../org/apache/hop/ui/core/gui/BaseGuiWidgets.java |  10 +
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  | 111 +++++
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  | 109 +++++
 .../configuration/tabs/ConfigGuiOptionsTab.java    | 213 ++++++++++
 .../core/dialog/messages/messages_en_US.properties |  17 +
 .../pipeline/messages/messages_en_US.properties    |   3 +
 .../workflow/messages/messages_en_US.properties    |   3 +
 14 files changed, 1802 insertions(+)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/hop-pipeline-editor.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/hop-pipeline-editor.adoc
index c538e600ef..fee0b9220e 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/hop-pipeline-editor.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/hop-pipeline-editor.adoc
@@ -49,4 +49,6 @@ 
image::getting-started/getting-started-pipeline-toolbar.png[Hop - Pipeline Toolb
 |||
 |distribute 
horizontally|image:getting-started/icons/distribute-horizontally.svg[Distribute 
Horizontally,25px,align="bottom"]|Distribute the selected transforms evenly 
between the left-most and right-most transform in your selection
 |distribute 
vertically|image:getting-started/icons/distribute-vertically.svg[Distribute 
Vertically,25px,align="bottom"]|Distribute the selected transforms evenly 
between the top-most and bottom-most transform in your selection
+|||
+|arrange 
(auto-layout)|image:getting-started/icons/auto-layout.svg[Auto-layout,25px,align="bottom"]|automatically
 arrange the transforms into a clean, layered left-to-right layout with no 
overlaps. Click (or `Ctrl/Cmd+Shift+L`) arranges the whole pipeline; 
Shift-click arranges only the selected transforms, keeping the rest in place. 
The whole operation is a single undo step. Notes are moved along with the 
transform they sit closest to (notes that aren't near any transform are left in 
pla [...]
 |===
diff --git 
a/engine/src/main/java/org/apache/hop/core/layout/LayeredGraphLayout.java 
b/engine/src/main/java/org/apache/hop/core/layout/LayeredGraphLayout.java
new file mode 100644
index 0000000000..cac632c0a9
--- /dev/null
+++ b/engine/src/main/java/org/apache/hop/core/layout/LayeredGraphLayout.java
@@ -0,0 +1,472 @@
+/*
+ * 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.core.layout;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Reusable layered (Sugiyama-style) DAG auto-layout core, decoupled from any 
particular Hop model.
+ *
+ * <p>It operates on an abstract graph: a node count {@code n} (nodes indexed 
0..n-1), a list of
+ * directed edges as {@code int[]{fromIndex, toIndex}}, and a {@link 
PositionSink} callback used to
+ * report the computed (x, y) coordinate for each node index. Both {@link
+ * org.apache.hop.pipeline.PipelineMetaLayout} and {@link
+ * org.apache.hop.workflow.WorkflowMetaLayout} are thin adapters over this 
class.
+ *
+ * <p>Algorithm:
+ *
+ * <ol>
+ *   <li>Build a directed graph from the supplied edges (deduplicated, 
self-loops dropped).
+ *   <li>Cycle removal: a DFS detects back-edges (edges pointing at a node 
currently on the DFS
+ *       stack). Back-edges are ignored for ranking only.
+ *   <li>Layer (rank/column) assignment via longest-path: roots (no forward 
incoming edge) get rank
+ *       0; every other node = max(rank(pred)) + 1.
+ *   <li>Ordering within a layer: barycenter sweeps (down then up, a few 
iterations) to reduce edge
+ *       crossings.
+ *   <li>Coordinate assignment: x = MARGIN_X + rank * X_SPACING, y depends on 
the order within the
+ *       layer. Weakly-connected components are stacked in non-overlapping 
vertical bands.
+ * </ol>
+ *
+ * <p>Robust against empty graphs, a single node, cycles and disconnected 
nodes.
+ */
+public final class LayeredGraphLayout {
+
+  public static final int DEFAULT_X_SPACING = 150;
+  public static final int DEFAULT_Y_SPACING = 120;
+  public static final int DEFAULT_ITERATIONS = 4;
+  public static final int MARGIN_X = 50;
+  public static final int MARGIN_Y = 50;
+
+  /** Hop snaps icon positions to a 16px grid. Keep spacing a multiple of 
this. */
+  private static final int GRID = 16;
+
+  /** The direction in which the graph flows from roots towards leaves. */
+  public enum Direction {
+    LEFT_RIGHT,
+    RIGHT_LEFT,
+    TOP_BOTTOM,
+    BOTTOM_TOP
+  }
+
+  /** Tunable layout options. Defaults reproduce the original left-to-right 
behaviour. */
+  public static final class Options {
+    private Direction direction = Direction.LEFT_RIGHT;
+    private int layerSpacing = DEFAULT_X_SPACING;
+    private int nodeSpacing = DEFAULT_Y_SPACING;
+    private int crossingIterations = DEFAULT_ITERATIONS;
+    private boolean moveNotes = true;
+
+    public Direction getDirection() {
+      return direction;
+    }
+
+    public Options setDirection(Direction direction) {
+      this.direction = direction == null ? Direction.LEFT_RIGHT : direction;
+      return this;
+    }
+
+    /** Distance between consecutive layers, along the flow direction. */
+    public int getLayerSpacing() {
+      return layerSpacing;
+    }
+
+    public Options setLayerSpacing(int layerSpacing) {
+      this.layerSpacing = layerSpacing;
+      return this;
+    }
+
+    /** Distance between nodes within the same layer, perpendicular to the 
flow direction. */
+    public int getNodeSpacing() {
+      return nodeSpacing;
+    }
+
+    public Options setNodeSpacing(int nodeSpacing) {
+      this.nodeSpacing = nodeSpacing;
+      return this;
+    }
+
+    /** Number of barycenter crossing-reduction sweeps. */
+    public int getCrossingIterations() {
+      return crossingIterations;
+    }
+
+    public Options setCrossingIterations(int crossingIterations) {
+      this.crossingIterations = crossingIterations;
+      return this;
+    }
+
+    /**
+     * Whether notes should be moved along with the node they sit closest to. 
The core layout
+     * ignores this flag; the {@code *MetaLayout} adapters honour it.
+     */
+    public boolean isMoveNotes() {
+      return moveNotes;
+    }
+
+    public Options setMoveNotes(boolean moveNotes) {
+      this.moveNotes = moveNotes;
+      return this;
+    }
+  }
+
+  /**
+   * Index of the node nearest to {@code (x, y)} whose distance is within 
{@code maxDistance}, or -1
+   * if no node is close enough. Used to bind free-floating items (e.g. notes) 
to a node.
+   *
+   * @param x the reference x coordinate
+   * @param y the reference y coordinate
+   * @param nodeX the node x coordinates
+   * @param nodeY the node y coordinates (same length as {@code nodeX})
+   * @param maxDistance the maximum distance to consider a node a match
+   * @return the index of the nearest in-range node, or -1
+   */
+  public static int nearestNode(int x, int y, int[] nodeX, int[] nodeY, double 
maxDistance) {
+    int best = -1;
+    double bestDistSq = maxDistance * maxDistance;
+    for (int i = 0; i < nodeX.length; i++) {
+      double dx = (double) x - nodeX[i];
+      double dy = (double) y - nodeY[i];
+      double distSq = dx * dx + dy * dy;
+      if (distSq <= bestDistSq) {
+        bestDistSq = distSq;
+        best = i;
+      }
+    }
+    return best;
+  }
+
+  /** Callback to receive the computed position for each node index. */
+  public interface PositionSink {
+    void setPosition(int nodeIndex, int x, int y);
+  }
+
+  private LayeredGraphLayout() {}
+
+  /** Layout with default options. */
+  public static void layout(int n, List<int[]> edges, PositionSink sink) {
+    layout(n, edges, new Options(), sink);
+  }
+
+  /**
+   * Layout with explicit spacing (left-to-right). Kept for backward 
compatibility.
+   *
+   * @param n number of nodes, indexed 0..n-1
+   * @param edges directed edges as {@code int[]{fromIndex, toIndex}}
+   * @param xSpacing horizontal distance between layers
+   * @param ySpacing vertical distance between nodes in a layer
+   * @param sink callback receiving the computed (x, y) per node index
+   */
+  public static void layout(
+      int n, List<int[]> edges, int xSpacing, int ySpacing, PositionSink sink) 
{
+    layout(n, edges, new 
Options().setLayerSpacing(xSpacing).setNodeSpacing(ySpacing), sink);
+  }
+
+  /**
+   * Layout with explicit options.
+   *
+   * @param n number of nodes, indexed 0..n-1
+   * @param edges directed edges as {@code int[]{fromIndex, toIndex}}
+   * @param options the layout tuning options (never null)
+   * @param sink callback receiving the computed (x, y) per node index
+   */
+  public static void layout(int n, List<int[]> edges, Options options, 
PositionSink sink) {
+    if (n <= 0 || sink == null) {
+      return;
+    }
+    if (options == null) {
+      options = new Options();
+    }
+
+    // Build adjacency (directed, deduplicated).
+    List<Set<Integer>> out = new ArrayList<>();
+    List<Set<Integer>> in = new ArrayList<>();
+    for (int i = 0; i < n; i++) {
+      out.add(new HashSet<>());
+      in.add(new HashSet<>());
+    }
+    if (edges != null) {
+      for (int[] e : edges) {
+        if (e == null || e.length < 2) {
+          continue;
+        }
+        int fi = e[0];
+        int ti = e[1];
+        if (fi < 0 || ti < 0 || fi >= n || ti >= n || fi == ti) {
+          continue;
+        }
+        out.get(fi).add(ti);
+        in.get(ti).add(fi);
+      }
+    }
+
+    // Cycle removal: find back-edges via DFS so layering uses only forward 
edges.
+    Set<Long> backEdges = findBackEdges(n, out);
+
+    // Forward adjacency (excluding back-edges).
+    List<List<Integer>> fOut = new ArrayList<>();
+    List<List<Integer>> fIn = new ArrayList<>();
+    int[] forwardInDegree = new int[n];
+    for (int i = 0; i < n; i++) {
+      fOut.add(new ArrayList<>());
+      fIn.add(new ArrayList<>());
+    }
+    for (int u = 0; u < n; u++) {
+      for (int v : out.get(u)) {
+        if (backEdges.contains(edgeKey(u, v))) {
+          continue;
+        }
+        fOut.get(u).add(v);
+        fIn.get(v).add(u);
+        forwardInDegree[v]++;
+      }
+    }
+
+    // Rank assignment via longest path (topological order on the DAG of 
forward edges).
+    int[] rank = new int[n];
+    int[] degree = forwardInDegree.clone();
+    List<Integer> queue = new ArrayList<>();
+    for (int i = 0; i < n; i++) {
+      if (degree[i] == 0) {
+        queue.add(i);
+      }
+    }
+    int qh = 0;
+    int processed = 0;
+    while (qh < queue.size()) {
+      int u = queue.get(qh++);
+      processed++;
+      for (int v : fOut.get(u)) {
+        if (rank[v] < rank[u] + 1) {
+          rank[v] = rank[u] + 1;
+        }
+        if (--degree[v] == 0) {
+          queue.add(v);
+        }
+      }
+    }
+    // Safety net: if cycle removal missed something, force any remaining 
nodes forward.
+    if (processed < n) {
+      for (int u = 0; u < n; u++) {
+        for (int v : fOut.get(u)) {
+          if (rank[v] <= rank[u]) {
+            rank[v] = rank[u] + 1;
+          }
+        }
+      }
+    }
+
+    // Group nodes by rank.
+    int maxRank = 0;
+    for (int i = 0; i < n; i++) {
+      maxRank = Math.max(maxRank, rank[i]);
+    }
+    List<List<Integer>> layers = new ArrayList<>();
+    for (int r = 0; r <= maxRank; r++) {
+      layers.add(new ArrayList<>());
+    }
+    for (int i = 0; i < n; i++) {
+      layers.get(rank[i]).add(i);
+    }
+
+    // Determine weakly-connected components (using all edges, both 
directions).
+    int[] component = new int[n];
+    java.util.Arrays.fill(component, -1);
+    int nrComponents = 0;
+    for (int s = 0; s < n; s++) {
+      if (component[s] != -1) {
+        continue;
+      }
+      List<Integer> stack = new ArrayList<>();
+      stack.add(s);
+      component[s] = nrComponents;
+      while (!stack.isEmpty()) {
+        int u = stack.remove(stack.size() - 1);
+        for (int v : out.get(u)) {
+          if (component[v] == -1) {
+            component[v] = nrComponents;
+            stack.add(v);
+          }
+        }
+        for (int v : in.get(u)) {
+          if (component[v] == -1) {
+            component[v] = nrComponents;
+            stack.add(v);
+          }
+        }
+      }
+      nrComponents++;
+    }
+
+    // Ordering within each layer: barycenter sweeps to reduce crossings.
+    Map<Integer, Integer> orderInLayer = new HashMap<>();
+    for (List<Integer> layer : layers) {
+      layer.sort(
+          (a, b) -> {
+            if (component[a] != component[b]) {
+              return Integer.compare(component[a], component[b]);
+            }
+            return Integer.compare(a, b);
+          });
+      for (int i = 0; i < layer.size(); i++) {
+        orderInLayer.put(layer.get(i), i);
+      }
+    }
+
+    int iterations = Math.max(0, options.getCrossingIterations());
+    for (int iter = 0; iter < iterations; iter++) {
+      boolean down = (iter % 2) == 0;
+      if (down) {
+        for (int r = 1; r <= maxRank; r++) {
+          sweep(layers.get(r), fIn, component, orderInLayer);
+        }
+      } else {
+        for (int r = maxRank - 1; r >= 0; r--) {
+          sweep(layers.get(r), fOut, component, orderInLayer);
+        }
+      }
+    }
+
+    // Spacing along the flow direction (between layers) and perpendicular 
(between nodes).
+    int flowSpace = snap(options.getLayerSpacing());
+    int crossSpace = snap(options.getNodeSpacing());
+    Direction direction = options.getDirection();
+    boolean horizontal = direction == Direction.LEFT_RIGHT || direction == 
Direction.RIGHT_LEFT;
+    boolean reversed = direction == Direction.RIGHT_LEFT || direction == 
Direction.BOTTOM_TOP;
+
+    // Assign a cross-axis band per component so components never overlap.
+    int[] componentBaseRow = new int[nrComponents];
+    int[] componentRows = new int[nrComponents];
+    for (List<Integer> layer : layers) {
+      int[] perComp = new int[nrComponents];
+      for (int node : layer) {
+        perComp[component[node]]++;
+      }
+      for (int c = 0; c < nrComponents; c++) {
+        componentRows[c] = Math.max(componentRows[c], perComp[c]);
+      }
+    }
+    int acc = 0;
+    for (int c = 0; c < nrComponents; c++) {
+      componentBaseRow[c] = acc;
+      acc += componentRows[c] + 1; // 1-row gap between component bands
+    }
+
+    // Place nodes. The rank gives the position along the flow axis, the order 
within a
+    // (layer, component) gives the position on the cross axis.
+    for (List<Integer> layer : layers) {
+      Map<Integer, Integer> rowInComp = new HashMap<>();
+      for (int node : layer) {
+        int c = component[node];
+        int row = rowInComp.getOrDefault(c, 0);
+        rowInComp.put(c, row + 1);
+
+        int flowIndex = reversed ? (maxRank - rank[node]) : rank[node];
+        int crossIndex = componentBaseRow[c] + row;
+        int along = flowIndex * flowSpace;
+        int cross = crossIndex * crossSpace;
+
+        int x = horizontal ? MARGIN_X + along : MARGIN_X + cross;
+        int y = horizontal ? MARGIN_Y + cross : MARGIN_Y + along;
+        sink.setPosition(node, snap(x), snap(y));
+      }
+    }
+  }
+
+  private static void sweep(
+      List<Integer> layer,
+      List<List<Integer>> neighbors,
+      int[] component,
+      Map<Integer, Integer> orderInLayer) {
+    Map<Integer, Double> bary = new HashMap<>();
+    for (int node : layer) {
+      List<Integer> nb = neighbors.get(node);
+      if (nb.isEmpty()) {
+        bary.put(node, (double) orderInLayer.getOrDefault(node, 0));
+      } else {
+        double sum = 0;
+        for (int m : nb) {
+          sum += orderInLayer.getOrDefault(m, 0);
+        }
+        bary.put(node, sum / nb.size());
+      }
+    }
+    layer.sort(
+        (a, b) -> {
+          if (component[a] != component[b]) {
+            return Integer.compare(component[a], component[b]);
+          }
+          int cmp = Double.compare(bary.get(a), bary.get(b));
+          if (cmp != 0) {
+            return cmp;
+          }
+          return Integer.compare(a, b);
+        });
+    for (int i = 0; i < layer.size(); i++) {
+      orderInLayer.put(layer.get(i), i);
+    }
+  }
+
+  /** DFS to identify back-edges (edges to a node currently on the recursion 
stack). */
+  private static Set<Long> findBackEdges(int n, List<Set<Integer>> out) {
+    Set<Long> backEdges = new HashSet<>();
+    int[] state = new int[n]; // 0=unvisited, 1=on-stack, 2=done
+    for (int start = 0; start < n; start++) {
+      if (state[start] != 0) {
+        continue;
+      }
+      List<int[]> stack = new ArrayList<>(); // {node, neighborCursor}
+      Map<Integer, List<Integer>> neighborLists = new HashMap<>();
+      stack.add(new int[] {start, 0});
+      state[start] = 1;
+      neighborLists.put(start, new ArrayList<>(out.get(start)));
+      while (!stack.isEmpty()) {
+        int[] top = stack.get(stack.size() - 1);
+        int u = top[0];
+        List<Integer> nb = neighborLists.get(u);
+        if (top[1] >= nb.size()) {
+          state[u] = 2;
+          stack.remove(stack.size() - 1);
+          continue;
+        }
+        int v = nb.get(top[1]);
+        top[1]++;
+        if (state[v] == 1) {
+          backEdges.add(edgeKey(u, v));
+        } else if (state[v] == 0) {
+          state[v] = 1;
+          neighborLists.put(v, new ArrayList<>(out.get(v)));
+          stack.add(new int[] {v, 0});
+        }
+      }
+    }
+    return backEdges;
+  }
+
+  private static long edgeKey(int u, int v) {
+    return (((long) u) << 32) | (v & 0xffffffffL);
+  }
+
+  private static int snap(int value) {
+    return Math.round((float) value / GRID) * GRID;
+  }
+}
diff --git 
a/engine/src/main/java/org/apache/hop/pipeline/PipelineMetaLayout.java 
b/engine/src/main/java/org/apache/hop/pipeline/PipelineMetaLayout.java
new file mode 100644
index 0000000000..1a628bd552
--- /dev/null
+++ b/engine/src/main/java/org/apache/hop/pipeline/PipelineMetaLayout.java
@@ -0,0 +1,192 @@
+/*
+ * 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.pipeline;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.NotePadMeta;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.layout.LayeredGraphLayout;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+/**
+ * Layered (Sugiyama-style) DAG auto-layout for a {@link PipelineMeta}.
+ *
+ * <p>It rewrites the x/y coordinates of every transform so the graph reads 
cleanly left-to-right
+ * with no overlaps. This is a thin adapter over {@link LayeredGraphLayout}; 
see that class for the
+ * algorithm details. Robust against empty pipelines, a single transform, 
cycles and disconnected
+ * nodes.
+ */
+public final class PipelineMetaLayout {
+
+  public static final int DEFAULT_X_SPACING = 
LayeredGraphLayout.DEFAULT_X_SPACING;
+  public static final int DEFAULT_Y_SPACING = 
LayeredGraphLayout.DEFAULT_Y_SPACING;
+  public static final int MARGIN_X = LayeredGraphLayout.MARGIN_X;
+  public static final int MARGIN_Y = LayeredGraphLayout.MARGIN_Y;
+
+  private PipelineMetaLayout() {}
+
+  /** Layout the whole pipeline with default options. */
+  public static void layout(PipelineMeta pipelineMeta) {
+    layout(pipelineMeta, new LayeredGraphLayout.Options());
+  }
+
+  /**
+   * Layout the whole pipeline with explicit options.
+   *
+   * @param pipelineMeta the pipeline to lay out
+   * @param options the layout tuning options
+   */
+  public static void layout(PipelineMeta pipelineMeta, 
LayeredGraphLayout.Options options) {
+    layout(pipelineMeta, options, null);
+  }
+
+  /**
+   * Layout the given pipeline. When {@code subset} is non-empty, only those 
transforms are arranged
+   * (using the hops between them) and the result is anchored to the top-left 
of the area the subset
+   * originally occupied, so the rest of the graph stays put. When {@code 
subset} is null or empty,
+   * the whole pipeline is laid out.
+   *
+   * @param pipelineMeta the pipeline to lay out
+   * @param options the layout tuning options
+   * @param subset the transforms to arrange, or null/empty for the whole 
pipeline
+   */
+  public static void layout(
+      PipelineMeta pipelineMeta, LayeredGraphLayout.Options options, 
List<TransformMeta> subset) {
+    if (pipelineMeta == null) {
+      return;
+    }
+    if (options == null) {
+      options = new LayeredGraphLayout.Options();
+    }
+
+    final List<TransformMeta> transforms = new ArrayList<>();
+    Map<TransformMeta, Integer> index = new HashMap<>();
+    if (subset == null || subset.isEmpty()) {
+      for (int i = 0; i < pipelineMeta.nrTransforms(); i++) {
+        TransformMeta t = pipelineMeta.getTransform(i);
+        index.put(t, transforms.size());
+        transforms.add(t);
+      }
+    } else {
+      for (TransformMeta t : subset) {
+        if (t != null && !index.containsKey(t)) {
+          index.put(t, transforms.size());
+          transforms.add(t);
+        }
+      }
+    }
+    int n = transforms.size();
+    if (n == 0) {
+      return;
+    }
+
+    List<int[]> edges = new ArrayList<>();
+    for (int h = 0; h < pipelineMeta.nrPipelineHops(); h++) {
+      PipelineHopMeta hop = pipelineMeta.getPipelineHop(h);
+      TransformMeta from = hop.getFromTransform();
+      TransformMeta to = hop.getToTransform();
+      if (from == null || to == null) {
+        continue;
+      }
+      Integer fi = index.get(from);
+      Integer ti = index.get(to);
+      if (fi == null || ti == null) {
+        continue;
+      }
+      edges.add(new int[] {fi, ti});
+    }
+
+    boolean anchor = subset != null && !subset.isEmpty();
+    Point origin = anchor ? topLeftOfTransforms(transforms) : null;
+    final Point[] computed = new Point[n];
+    LayeredGraphLayout.layout(n, edges, options, (node, x, y) -> 
computed[node] = new Point(x, y));
+
+    // Translate so the arranged block lands where the selection used to be.
+    int dx = 0;
+    int dy = 0;
+    if (anchor) {
+      Point computedMin = topLeft(computed);
+      dx = origin.x - computedMin.x;
+      dy = origin.y - computedMin.y;
+    }
+
+    // Capture the node positions before/after so notes can follow the node 
they're closest to.
+    int[] nodeBeforeX = new int[n];
+    int[] nodeBeforeY = new int[n];
+    int[] nodeAfterX = new int[n];
+    int[] nodeAfterY = new int[n];
+    for (int i = 0; i < n; i++) {
+      Point before = transforms.get(i).getLocation();
+      nodeBeforeX[i] = before.x;
+      nodeBeforeY[i] = before.y;
+      if (computed[i] != null) {
+        nodeAfterX[i] = computed[i].x + dx;
+        nodeAfterY[i] = computed[i].y + dy;
+        transforms.get(i).setLocation(nodeAfterX[i], nodeAfterY[i]);
+      } else {
+        nodeAfterX[i] = before.x;
+        nodeAfterY[i] = before.y;
+      }
+    }
+
+    if (options.isMoveNotes()) {
+      double threshold = Math.max(options.getLayerSpacing(), 
options.getNodeSpacing());
+      for (int i = 0; i < pipelineMeta.nrNotes(); i++) {
+        NotePadMeta note = pipelineMeta.getNote(i);
+        Point p = note.getLocation();
+        if (p == null) {
+          continue;
+        }
+        int nearest = LayeredGraphLayout.nearestNode(p.x, p.y, nodeBeforeX, 
nodeBeforeY, threshold);
+        if (nearest >= 0) {
+          note.setLocation(
+              p.x + (nodeAfterX[nearest] - nodeBeforeX[nearest]),
+              p.y + (nodeAfterY[nearest] - nodeBeforeY[nearest]));
+        }
+      }
+    }
+  }
+
+  /** The minimum x and minimum y across the locations of the given transforms 
(as one Point). */
+  private static Point topLeftOfTransforms(List<TransformMeta> transforms) {
+    int minX = Integer.MAX_VALUE;
+    int minY = Integer.MAX_VALUE;
+    for (TransformMeta t : transforms) {
+      Point p = t.getLocation();
+      minX = Math.min(minX, p.x);
+      minY = Math.min(minY, p.y);
+    }
+    return new Point(minX, minY);
+  }
+
+  /** The minimum x and minimum y across the given points (as one Point). */
+  private static Point topLeft(Point[] points) {
+    int minX = Integer.MAX_VALUE;
+    int minY = Integer.MAX_VALUE;
+    for (Point p : points) {
+      if (p != null) {
+        minX = Math.min(minX, p.x);
+        minY = Math.min(minY, p.y);
+      }
+    }
+    return new Point(minX, minY);
+  }
+}
diff --git 
a/engine/src/main/java/org/apache/hop/workflow/WorkflowMetaLayout.java 
b/engine/src/main/java/org/apache/hop/workflow/WorkflowMetaLayout.java
new file mode 100644
index 0000000000..30dd180f7c
--- /dev/null
+++ b/engine/src/main/java/org/apache/hop/workflow/WorkflowMetaLayout.java
@@ -0,0 +1,192 @@
+/*
+ * 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.workflow;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.NotePadMeta;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.layout.LayeredGraphLayout;
+import org.apache.hop.workflow.action.ActionMeta;
+
+/**
+ * Layered (Sugiyama-style) DAG auto-layout for a {@link WorkflowMeta}.
+ *
+ * <p>It rewrites the x/y coordinates of every action so the graph reads 
cleanly left-to-right with
+ * no overlaps. This is a thin adapter over {@link LayeredGraphLayout}; see 
that class for the
+ * algorithm details. Robust against empty workflows, a single action, cycles 
(workflows commonly
+ * loop back) and disconnected nodes.
+ */
+public final class WorkflowMetaLayout {
+
+  public static final int DEFAULT_X_SPACING = 
LayeredGraphLayout.DEFAULT_X_SPACING;
+  public static final int DEFAULT_Y_SPACING = 
LayeredGraphLayout.DEFAULT_Y_SPACING;
+  public static final int MARGIN_X = LayeredGraphLayout.MARGIN_X;
+  public static final int MARGIN_Y = LayeredGraphLayout.MARGIN_Y;
+
+  private WorkflowMetaLayout() {}
+
+  /** Layout the whole workflow with default options. */
+  public static void layout(WorkflowMeta workflowMeta) {
+    layout(workflowMeta, new LayeredGraphLayout.Options());
+  }
+
+  /**
+   * Layout the whole workflow with explicit options.
+   *
+   * @param workflowMeta the workflow to lay out
+   * @param options the layout tuning options
+   */
+  public static void layout(WorkflowMeta workflowMeta, 
LayeredGraphLayout.Options options) {
+    layout(workflowMeta, options, null);
+  }
+
+  /**
+   * Layout the given workflow. When {@code subset} is non-empty, only those 
actions are arranged
+   * (using the hops between them) and the result is anchored to the top-left 
of the area the subset
+   * originally occupied, so the rest of the graph stays put. When {@code 
subset} is null or empty,
+   * the whole workflow is laid out.
+   *
+   * @param workflowMeta the workflow to lay out
+   * @param options the layout tuning options
+   * @param subset the actions to arrange, or null/empty for the whole workflow
+   */
+  public static void layout(
+      WorkflowMeta workflowMeta, LayeredGraphLayout.Options options, 
List<ActionMeta> subset) {
+    if (workflowMeta == null) {
+      return;
+    }
+    if (options == null) {
+      options = new LayeredGraphLayout.Options();
+    }
+
+    final List<ActionMeta> actions = new ArrayList<>();
+    Map<ActionMeta, Integer> index = new HashMap<>();
+    if (subset == null || subset.isEmpty()) {
+      for (int i = 0; i < workflowMeta.nrActions(); i++) {
+        ActionMeta a = workflowMeta.getAction(i);
+        index.put(a, actions.size());
+        actions.add(a);
+      }
+    } else {
+      for (ActionMeta a : subset) {
+        if (a != null && !index.containsKey(a)) {
+          index.put(a, actions.size());
+          actions.add(a);
+        }
+      }
+    }
+    int n = actions.size();
+    if (n == 0) {
+      return;
+    }
+
+    List<int[]> edges = new ArrayList<>();
+    for (int h = 0; h < workflowMeta.nrWorkflowHops(); h++) {
+      WorkflowHopMeta hop = workflowMeta.getWorkflowHop(h);
+      ActionMeta from = hop.getFromAction();
+      ActionMeta to = hop.getToAction();
+      if (from == null || to == null) {
+        continue;
+      }
+      Integer fi = index.get(from);
+      Integer ti = index.get(to);
+      if (fi == null || ti == null) {
+        continue;
+      }
+      edges.add(new int[] {fi, ti});
+    }
+
+    boolean anchor = subset != null && !subset.isEmpty();
+    Point origin = anchor ? topLeftOfActions(actions) : null;
+    final Point[] computed = new Point[n];
+    LayeredGraphLayout.layout(n, edges, options, (node, x, y) -> 
computed[node] = new Point(x, y));
+
+    // Translate so the arranged block lands where the selection used to be.
+    int dx = 0;
+    int dy = 0;
+    if (anchor) {
+      Point computedMin = topLeft(computed);
+      dx = origin.x - computedMin.x;
+      dy = origin.y - computedMin.y;
+    }
+
+    // Capture the node positions before/after so notes can follow the node 
they're closest to.
+    int[] nodeBeforeX = new int[n];
+    int[] nodeBeforeY = new int[n];
+    int[] nodeAfterX = new int[n];
+    int[] nodeAfterY = new int[n];
+    for (int i = 0; i < n; i++) {
+      Point before = actions.get(i).getLocation();
+      nodeBeforeX[i] = before.x;
+      nodeBeforeY[i] = before.y;
+      if (computed[i] != null) {
+        nodeAfterX[i] = computed[i].x + dx;
+        nodeAfterY[i] = computed[i].y + dy;
+        actions.get(i).setLocation(nodeAfterX[i], nodeAfterY[i]);
+      } else {
+        nodeAfterX[i] = before.x;
+        nodeAfterY[i] = before.y;
+      }
+    }
+
+    if (options.isMoveNotes()) {
+      double threshold = Math.max(options.getLayerSpacing(), 
options.getNodeSpacing());
+      for (int i = 0; i < workflowMeta.nrNotes(); i++) {
+        NotePadMeta note = workflowMeta.getNote(i);
+        Point p = note.getLocation();
+        if (p == null) {
+          continue;
+        }
+        int nearest = LayeredGraphLayout.nearestNode(p.x, p.y, nodeBeforeX, 
nodeBeforeY, threshold);
+        if (nearest >= 0) {
+          note.setLocation(
+              p.x + (nodeAfterX[nearest] - nodeBeforeX[nearest]),
+              p.y + (nodeAfterY[nearest] - nodeBeforeY[nearest]));
+        }
+      }
+    }
+  }
+
+  /** The minimum x and minimum y across the locations of the given actions 
(as one Point). */
+  private static Point topLeftOfActions(List<ActionMeta> actions) {
+    int minX = Integer.MAX_VALUE;
+    int minY = Integer.MAX_VALUE;
+    for (ActionMeta a : actions) {
+      Point p = a.getLocation();
+      minX = Math.min(minX, p.x);
+      minY = Math.min(minY, p.y);
+    }
+    return new Point(minX, minY);
+  }
+
+  /** The minimum x and minimum y across the given points (as one Point). */
+  private static Point topLeft(Point[] points) {
+    int minX = Integer.MAX_VALUE;
+    int minY = Integer.MAX_VALUE;
+    for (Point p : points) {
+      if (p != null) {
+        minX = Math.min(minX, p.x);
+        minY = Math.min(minY, p.y);
+      }
+    }
+    return new Point(minX, minY);
+  }
+}
diff --git 
a/engine/src/test/java/org/apache/hop/pipeline/PipelineMetaLayoutTest.java 
b/engine/src/test/java/org/apache/hop/pipeline/PipelineMetaLayoutTest.java
new file mode 100644
index 0000000000..39b6a286a2
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/pipeline/PipelineMetaLayoutTest.java
@@ -0,0 +1,258 @@
+/*
+ * 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.pipeline;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import org.apache.hop.core.NotePadMeta;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.layout.LayeredGraphLayout;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.junit.jupiter.api.Test;
+
+public class PipelineMetaLayoutTest {
+
+  private TransformMeta transform(String name) {
+    TransformMeta t = new TransformMeta();
+    t.setName(name);
+    t.setLocation(0, 0);
+    return t;
+  }
+
+  private void hop(PipelineMeta meta, TransformMeta from, TransformMeta to) {
+    meta.addPipelineHop(new PipelineHopMeta(from, to));
+  }
+
+  @Test
+  public void testLayoutNoOverlapAndLeftToRight() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta a = transform("a");
+    TransformMeta b = transform("b");
+    TransformMeta c = transform("c");
+    TransformMeta d = transform("d"); // fan: a -> d, then d -> c (join into c)
+    meta.addTransform(a);
+    meta.addTransform(b);
+    meta.addTransform(c);
+    meta.addTransform(d);
+    hop(meta, a, b);
+    hop(meta, b, c);
+    hop(meta, a, d);
+    hop(meta, d, c);
+
+    PipelineMetaLayout.layout(meta);
+
+    // (a) no two transforms share the same (x,y)
+    Set<String> coords = new HashSet<>();
+    for (int i = 0; i < meta.nrTransforms(); i++) {
+      Point p = meta.getTransform(i).getLocation();
+      assertTrue(coords.add(p.x + ":" + p.y), "Duplicate coordinate at " + p.x 
+ "," + p.y);
+    }
+
+    // (b) every forward hop goes left-to-right
+    for (int i = 0; i < meta.nrPipelineHops(); i++) {
+      PipelineHopMeta h = meta.getPipelineHop(i);
+      Point from = h.getFromTransform().getLocation();
+      Point to = h.getToTransform().getLocation();
+      assertTrue(
+          to.x > from.x,
+          h.getFromTransform().getName()
+              + " -> "
+              + h.getToTransform().getName()
+              + " not left-to-right ("
+              + from.x
+              + " >= "
+              + to.x
+              + ")");
+    }
+  }
+
+  @Test
+  public void testNoteFollowsNearestNodeButDistantNoteStaysPut() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta a = transform("a");
+    TransformMeta b = transform("b");
+    a.setLocation(100, 100);
+    b.setLocation(120, 5000); // far away on the canvas
+    meta.addTransform(a);
+    meta.addTransform(b);
+    hop(meta, a, b);
+
+    // A note right next to 'a', and a note far from everything.
+    NotePadMeta nearNote = new NotePadMeta("near a", 110, 110, 50, 20);
+    NotePadMeta farNote = new NotePadMeta("orphan", 9000, 9000, 50, 20);
+    meta.addNote(nearNote);
+    meta.addNote(farNote);
+
+    int aDx = a.getLocation().x; // captured before layout
+    int aDy = a.getLocation().y;
+
+    PipelineMetaLayout.layout(meta, new LayeredGraphLayout.Options());
+
+    // The near note moved by the same delta as transform 'a'.
+    aDx = a.getLocation().x - aDx;
+    aDy = a.getLocation().y - aDy;
+    assertEquals(110 + aDx, nearNote.getLocation().x);
+    assertEquals(110 + aDy, nearNote.getLocation().y);
+
+    // The far note was left untouched.
+    assertEquals(9000, farNote.getLocation().x);
+    assertEquals(9000, farNote.getLocation().y);
+  }
+
+  @Test
+  public void testMoveNotesDisabledLeavesNotesAlone() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta a = transform("a");
+    TransformMeta b = transform("b");
+    a.setLocation(100, 100);
+    b.setLocation(200, 100);
+    meta.addTransform(a);
+    meta.addTransform(b);
+    hop(meta, a, b);
+    NotePadMeta note = new NotePadMeta("near a", 110, 110, 50, 20);
+    meta.addNote(note);
+
+    PipelineMetaLayout.layout(meta, new 
LayeredGraphLayout.Options().setMoveNotes(false));
+
+    assertEquals(110, note.getLocation().x);
+    assertEquals(110, note.getLocation().y);
+  }
+
+  @Test
+  public void testTopBottomDirectionFlowsDownward() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta a = transform("a");
+    TransformMeta b = transform("b");
+    TransformMeta c = transform("c");
+    meta.addTransform(a);
+    meta.addTransform(b);
+    meta.addTransform(c);
+    hop(meta, a, b);
+    hop(meta, b, c);
+
+    PipelineMetaLayout.layout(
+        meta,
+        new 
LayeredGraphLayout.Options().setDirection(LayeredGraphLayout.Direction.TOP_BOTTOM));
+
+    // Every forward hop goes top-to-bottom.
+    for (int i = 0; i < meta.nrPipelineHops(); i++) {
+      PipelineHopMeta h = meta.getPipelineHop(i);
+      assertTrue(
+          h.getToTransform().getLocation().y > 
h.getFromTransform().getLocation().y,
+          "hop not top-to-bottom");
+    }
+  }
+
+  @Test
+  public void testSelectionSubsetLeavesUnselectedInPlaceAndAnchors() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta a = transform("a");
+    TransformMeta b = transform("b");
+    TransformMeta other = transform("other");
+    a.setLocation(1000, 2000);
+    b.setLocation(3000, 50);
+    other.setLocation(77, 88);
+    meta.addTransform(a);
+    meta.addTransform(b);
+    meta.addTransform(other);
+    hop(meta, a, b);
+
+    // Lay out only {a, b}; 'other' is not part of the subset.
+    PipelineMetaLayout.layout(meta, new LayeredGraphLayout.Options(), 
Arrays.asList(a, b));
+
+    // The unselected transform must not have moved.
+    assertEquals(77, other.getLocation().x);
+    assertEquals(88, other.getLocation().y);
+
+    // The arranged block is anchored to the top-left of where the subset was 
(minX=1000, minY=50).
+    int minX = Math.min(a.getLocation().x, b.getLocation().x);
+    int minY = Math.min(a.getLocation().y, b.getLocation().y);
+    assertEquals(1000, minX);
+    assertEquals(50, minY);
+
+    // And it still reads left-to-right.
+    assertTrue(b.getLocation().x > a.getLocation().x, "subset not 
left-to-right");
+  }
+
+  @Test
+  public void testEmptyPipelineDoesNotThrow() {
+    PipelineMetaLayout.layout(new PipelineMeta());
+  }
+
+  @Test
+  public void testNullDoesNotThrow() {
+    PipelineMetaLayout.layout(null);
+  }
+
+  @Test
+  public void testSingleTransformDoesNotThrow() {
+    PipelineMeta meta = new PipelineMeta();
+    meta.addTransform(transform("only"));
+    PipelineMetaLayout.layout(meta);
+  }
+
+  @Test
+  public void testCyclicPipelineDoesNotThrow() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta a = transform("a");
+    TransformMeta b = transform("b");
+    TransformMeta c = transform("c");
+    meta.addTransform(a);
+    meta.addTransform(b);
+    meta.addTransform(c);
+    hop(meta, a, b);
+    hop(meta, b, c);
+    hop(meta, c, a); // cycle
+
+    PipelineMetaLayout.layout(meta);
+
+    // No duplicate coordinates even with a cycle.
+    Set<String> coords = new HashSet<>();
+    for (int i = 0; i < meta.nrTransforms(); i++) {
+      Point p = meta.getTransform(i).getLocation();
+      assertTrue(coords.add(p.x + ":" + p.y));
+    }
+  }
+
+  @Test
+  public void testDisconnectedComponentsDoNotOverlap() {
+    PipelineMeta meta = new PipelineMeta();
+    TransformMeta a = transform("a");
+    TransformMeta b = transform("b");
+    TransformMeta x = transform("x"); // separate component
+    TransformMeta y = transform("y");
+    meta.addTransform(a);
+    meta.addTransform(b);
+    meta.addTransform(x);
+    meta.addTransform(y);
+    hop(meta, a, b);
+    hop(meta, x, y);
+
+    PipelineMetaLayout.layout(meta);
+
+    Set<String> coords = new HashSet<>();
+    for (int i = 0; i < meta.nrTransforms(); i++) {
+      Point p = meta.getTransform(i).getLocation();
+      assertTrue(coords.add(p.x + ":" + p.y));
+    }
+  }
+}
diff --git 
a/engine/src/test/java/org/apache/hop/workflow/WorkflowMetaLayoutTest.java 
b/engine/src/test/java/org/apache/hop/workflow/WorkflowMetaLayoutTest.java
new file mode 100644
index 0000000000..a92d7196b7
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/workflow/WorkflowMetaLayoutTest.java
@@ -0,0 +1,147 @@
+/*
+ * 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.workflow;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashSet;
+import java.util.Set;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.workflow.action.ActionMeta;
+import org.apache.hop.workflow.actions.dummy.ActionDummy;
+import org.junit.jupiter.api.Test;
+
+public class WorkflowMetaLayoutTest {
+
+  private ActionMeta action(String name) {
+    ActionDummy dummy = new ActionDummy();
+    dummy.setName(name);
+    ActionMeta meta = new ActionMeta(dummy);
+    meta.setLocation(0, 0);
+    return meta;
+  }
+
+  private void hop(WorkflowMeta meta, ActionMeta from, ActionMeta to) {
+    meta.addWorkflowHop(new WorkflowHopMeta(from, to));
+  }
+
+  @Test
+  public void testLayoutNoOverlapAndLeftToRight() {
+    WorkflowMeta meta = new WorkflowMeta();
+    ActionMeta a = action("a");
+    ActionMeta b = action("b");
+    ActionMeta c = action("c");
+    ActionMeta d = action("d"); // fan: a -> d, then d -> c (join into c)
+    meta.addAction(a);
+    meta.addAction(b);
+    meta.addAction(c);
+    meta.addAction(d);
+    hop(meta, a, b);
+    hop(meta, b, c);
+    hop(meta, a, d);
+    hop(meta, d, c);
+
+    WorkflowMetaLayout.layout(meta);
+
+    // (a) no two actions share the same (x,y)
+    Set<String> coords = new HashSet<>();
+    for (int i = 0; i < meta.nrActions(); i++) {
+      Point p = meta.getAction(i).getLocation();
+      assertTrue(coords.add(p.x + ":" + p.y), "Duplicate coordinate at " + p.x 
+ "," + p.y);
+    }
+
+    // (b) every forward hop goes left-to-right
+    for (int i = 0; i < meta.nrWorkflowHops(); i++) {
+      WorkflowHopMeta h = meta.getWorkflowHop(i);
+      Point from = h.getFromAction().getLocation();
+      Point to = h.getToAction().getLocation();
+      assertTrue(
+          to.x > from.x,
+          h.getFromAction().getName()
+              + " -> "
+              + h.getToAction().getName()
+              + " not left-to-right ("
+              + from.x
+              + " >= "
+              + to.x
+              + ")");
+    }
+  }
+
+  @Test
+  public void testEmptyWorkflowDoesNotThrow() {
+    WorkflowMetaLayout.layout(new WorkflowMeta());
+  }
+
+  @Test
+  public void testNullDoesNotThrow() {
+    WorkflowMetaLayout.layout(null);
+  }
+
+  @Test
+  public void testSingleActionDoesNotThrow() {
+    WorkflowMeta meta = new WorkflowMeta();
+    meta.addAction(action("only"));
+    WorkflowMetaLayout.layout(meta);
+  }
+
+  @Test
+  public void testCyclicWorkflowDoesNotThrow() {
+    WorkflowMeta meta = new WorkflowMeta();
+    ActionMeta a = action("a");
+    ActionMeta b = action("b");
+    ActionMeta c = action("c");
+    meta.addAction(a);
+    meta.addAction(b);
+    meta.addAction(c);
+    hop(meta, a, b);
+    hop(meta, b, c);
+    hop(meta, c, a); // cycle
+
+    WorkflowMetaLayout.layout(meta);
+
+    Set<String> coords = new HashSet<>();
+    for (int i = 0; i < meta.nrActions(); i++) {
+      Point p = meta.getAction(i).getLocation();
+      assertTrue(coords.add(p.x + ":" + p.y));
+    }
+  }
+
+  @Test
+  public void testDisconnectedComponentsDoNotOverlap() {
+    WorkflowMeta meta = new WorkflowMeta();
+    ActionMeta a = action("a");
+    ActionMeta b = action("b");
+    ActionMeta x = action("x"); // separate component
+    ActionMeta y = action("y");
+    meta.addAction(a);
+    meta.addAction(b);
+    meta.addAction(x);
+    meta.addAction(y);
+    hop(meta, a, b);
+    hop(meta, x, y);
+
+    WorkflowMetaLayout.layout(meta);
+
+    Set<String> coords = new HashSet<>();
+    for (int i = 0; i < meta.nrActions(); i++) {
+      Point p = meta.getAction(i).getLocation();
+      assertTrue(coords.add(p.x + ":" + p.y));
+    }
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java 
b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
index 79a7cd0f28..a40367a16f 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
@@ -25,6 +25,7 @@ import org.apache.hop.core.Props;
 import org.apache.hop.core.gui.IGuiPosition;
 import org.apache.hop.core.gui.IGuiSize;
 import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.layout.LayeredGraphLayout;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.history.AuditManager;
@@ -72,6 +73,11 @@ public class PropsUi extends Props {
   private static final String HIDE_MENU_BAR = "HideMenuBar";
   private static final String SORT_FIELD_BY_NAME = "SortFieldByName";
   private static final String CANVAS_GRID_SIZE = "CanvasGridSize";
+  private static final String AUTO_LAYOUT_DIRECTION = "AutoLayoutDirection";
+  private static final String AUTO_LAYOUT_LAYER_SPACING = 
"AutoLayoutLayerSpacing";
+  private static final String AUTO_LAYOUT_NODE_SPACING = 
"AutoLayoutNodeSpacing";
+  private static final String AUTO_LAYOUT_CROSSING_ITERATIONS = 
"AutoLayoutCrossingIterations";
+  private static final String AUTO_LAYOUT_MOVE_NOTES = "AutoLayoutMoveNotes";
   private static final String LEGACY_PERSPECTIVE_MODE = 
"LegacyPerspectiveMode";
   private static final String DISABLE_BROWSER_ENVIRONMENT_CHECK = 
"DisableBrowserEnvironmentCheck";
   private static final String USE_DOUBLE_CLICK_ON_CANVAS = 
"UseDoubleClickOnCanvas";
@@ -1165,6 +1171,73 @@ public class PropsUi extends Props {
     setProperty(CANVAS_GRID_SIZE, Integer.toString(gridSize));
   }
 
+  /**
+   * The auto-layout flow direction, as a {@link
+   * org.apache.hop.core.layout.LayeredGraphLayout.Direction} name. Defaults 
to {@code LEFT_RIGHT}.
+   */
+  public String getAutoLayoutDirection() {
+    return getProperty(AUTO_LAYOUT_DIRECTION, "LEFT_RIGHT");
+  }
+
+  public void setAutoLayoutDirection(String direction) {
+    setProperty(AUTO_LAYOUT_DIRECTION, direction);
+  }
+
+  /** Auto-layout distance between consecutive layers, along the flow 
direction. */
+  public int getAutoLayoutLayerSpacing() {
+    return Const.toInt(
+        getProperty(AUTO_LAYOUT_LAYER_SPACING, ""), 
LayeredGraphLayout.DEFAULT_X_SPACING);
+  }
+
+  public void setAutoLayoutLayerSpacing(int spacing) {
+    setProperty(AUTO_LAYOUT_LAYER_SPACING, Integer.toString(spacing));
+  }
+
+  /** Auto-layout distance between nodes within a layer, perpendicular to the 
flow direction. */
+  public int getAutoLayoutNodeSpacing() {
+    return Const.toInt(
+        getProperty(AUTO_LAYOUT_NODE_SPACING, ""), 
LayeredGraphLayout.DEFAULT_Y_SPACING);
+  }
+
+  public void setAutoLayoutNodeSpacing(int spacing) {
+    setProperty(AUTO_LAYOUT_NODE_SPACING, Integer.toString(spacing));
+  }
+
+  /** Auto-layout number of barycenter crossing-reduction sweeps. */
+  public int getAutoLayoutCrossingIterations() {
+    return Const.toInt(
+        getProperty(AUTO_LAYOUT_CROSSING_ITERATIONS, ""), 
LayeredGraphLayout.DEFAULT_ITERATIONS);
+  }
+
+  public void setAutoLayoutCrossingIterations(int iterations) {
+    setProperty(AUTO_LAYOUT_CROSSING_ITERATIONS, Integer.toString(iterations));
+  }
+
+  /** Whether auto-layout moves notes along with the node they sit closest to. 
Defaults to true. */
+  public boolean isAutoLayoutMoveNotes() {
+    return YES.equalsIgnoreCase(getProperty(AUTO_LAYOUT_MOVE_NOTES, YES));
+  }
+
+  public void setAutoLayoutMoveNotes(boolean moveNotes) {
+    setProperty(AUTO_LAYOUT_MOVE_NOTES, moveNotes ? YES : NO);
+  }
+
+  /** Build layout options from the stored auto-layout preferences. */
+  public LayeredGraphLayout.Options getAutoLayoutOptions() {
+    LayeredGraphLayout.Direction direction;
+    try {
+      direction = 
LayeredGraphLayout.Direction.valueOf(getAutoLayoutDirection());
+    } catch (IllegalArgumentException e) {
+      direction = LayeredGraphLayout.Direction.LEFT_RIGHT;
+    }
+    return new LayeredGraphLayout.Options()
+        .setDirection(direction)
+        .setLayerSpacing(getAutoLayoutLayerSpacing())
+        .setNodeSpacing(getAutoLayoutNodeSpacing())
+        .setCrossingIterations(getAutoLayoutCrossingIterations())
+        .setMoveNotes(isAutoLayoutMoveNotes());
+  }
+
   /**
    * Gets the supported version of the requested software.
    *
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java
index d6acd05542..b029792d3c 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/BaseGuiWidgets.java
@@ -29,6 +29,7 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.HopGuiKeyHandler;
 import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Event;
 import org.eclipse.swt.widgets.Listener;
 
 public class BaseGuiWidgets {
@@ -211,6 +212,15 @@ public class BaseGuiWidgets {
 
         Object guiPluginInstance =
             findGuiPluginInstance(classLoader, listenerClassName, instanceId);
+        // Prefer a method that accepts the SWT Event (e.g. to read modifier 
keys such as Shift).
+        try {
+          Method withEvent =
+              
guiPluginInstance.getClass().getDeclaredMethod(listenerMethodName, Event.class);
+          withEvent.invoke(guiPluginInstance, e);
+          return;
+        } catch (NoSuchMethodException noEventMethod) {
+          // Fall back to the standard no-argument method.
+        }
         Method listenerMethod = 
guiPluginInstance.getClass().getDeclaredMethod(listenerMethodName);
         listenerMethod.invoke(guiPluginInstance);
 
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 a3f1c6a14b..9c06af3af3 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
@@ -103,6 +103,7 @@ import org.apache.hop.pipeline.DatabaseImpact;
 import org.apache.hop.pipeline.PipelineExecutionConfiguration;
 import org.apache.hop.pipeline.PipelineHopMeta;
 import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.PipelineMetaLayout;
 import org.apache.hop.pipeline.PipelinePainter;
 import org.apache.hop.pipeline.config.PipelineRunConfiguration;
 import org.apache.hop.pipeline.debug.PipelineDebugMeta;
@@ -205,6 +206,7 @@ import org.eclipse.swt.widgets.Combo;
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Event;
 import org.eclipse.swt.widgets.Listener;
 import org.eclipse.swt.widgets.Menu;
 import org.eclipse.swt.widgets.MenuItem;
@@ -254,6 +256,8 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
       "HopGuiPipelineGraph-ToolBar-10530-Zoom-100Pct";
   public static final String TOOLBAR_ITEM_ZOOM_TO_FIT =
       "HopGuiPipelineGraph-ToolBar-10540-Zoom-To-Fit";
+  public static final String TOOLBAR_ITEM_AUTO_LAYOUT =
+      "HopGuiPipelineGraph-ToolBar-10545-Auto-Layout";
 
   public static final String TOOLBAR_ITEM_DESIGN_ENGINE =
       "HopGuiPipelineGraph-ToolBar-10550-Design-Engine";
@@ -2196,6 +2200,99 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     super.zoom100Percent();
   }
 
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_AUTO_LAYOUT,
+      toolTip = "i18n::HopGuiPipelineGraph.GuiAction.AutoLayout.Tooltip",
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/auto-layout.svg")
+  public void autoLayoutPipeline(Event e) {
+    // Click arranges the whole graph; Shift-click arranges only the selected 
transforms.
+    boolean selectionOnly = e != null && (e.stateMask & SWT.SHIFT) != 0;
+    applyAutoLayout(selectionOnly);
+  }
+
+  @GuiKeyboardShortcut(control = true, shift = true, key = 'l')
+  @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'l')
+  public void autoLayoutPipelineShortcut() {
+    applyAutoLayout(false);
+  }
+
+  /**
+   * Auto-layout the pipeline, recording all the moves as a single undo action.
+   *
+   * @param selectionOnly when true only the selected transforms are arranged 
(anchored to where the
+   *     selection was); otherwise the whole pipeline is arranged.
+   */
+  private void applyAutoLayout(boolean selectionOnly) {
+    selectionRegion = null;
+
+    List<TransformMeta> subset = null;
+    List<TransformMeta> moving;
+    if (selectionOnly) {
+      subset = pipelineMeta.getSelectedTransforms();
+      if (subset == null || subset.size() < 2) {
+        return; // Nothing meaningful to arrange.
+      }
+      moving = new ArrayList<>(subset);
+    } else {
+      int n = pipelineMeta.nrTransforms();
+      if (n == 0) {
+        return;
+      }
+      moving = new ArrayList<>(n);
+      for (int i = 0; i < n; i++) {
+        moving.add(pipelineMeta.getTransform(i));
+      }
+    }
+
+    // Auto-layout may also reposition notes; capture them so the whole thing 
is one undo step.
+    List<NotePadMeta> notes = new ArrayList<>(pipelineMeta.getNotes());
+    Point[] notesBefore = captureNoteLocations(notes);
+
+    Point[] before = captureLocations(moving);
+    PipelineMetaLayout.layout(pipelineMeta, 
PropsUi.getInstance().getAutoLayoutOptions(), subset);
+    Point[] after = captureLocations(moving);
+    Point[] notesAfter = captureNoteLocations(notes);
+
+    // Record notes first, then transforms, linked into a single undo action 
(nextAlso).
+    boolean also = false;
+    if (!notes.isEmpty()) {
+      also = true;
+      hopGui.undoDelegate.addUndoPosition(
+          pipelineMeta,
+          notes.toArray(new NotePadMeta[0]),
+          pipelineMeta.getNoteIndexes(notes),
+          notesBefore,
+          notesAfter,
+          also);
+    }
+    int[] indexes = pipelineMeta.getTransformIndexes(moving);
+    hopGui.undoDelegate.addUndoPosition(
+        pipelineMeta, moving.toArray(new TransformMeta[0]), indexes, before, 
after, also);
+
+    pipelineMeta.setChanged();
+    updateGui();
+  }
+
+  private static Point[] captureLocations(List<TransformMeta> transforms) {
+    Point[] points = new Point[transforms.size()];
+    for (int i = 0; i < transforms.size(); i++) {
+      Point p = transforms.get(i).getLocation();
+      points[i] = new Point(p.x, p.y);
+    }
+    return points;
+  }
+
+  private static Point[] captureNoteLocations(List<NotePadMeta> notes) {
+    Point[] points = new Point[notes.size()];
+    for (int i = 0; i < notes.size(); i++) {
+      Point p = notes.get(i).getLocation();
+      points[i] = new Point(p.x, p.y);
+    }
+    return points;
+  }
+
   public List<String> getZoomLevels() {
     return Arrays.asList(PipelinePainter.magnificationDescriptions);
   }
@@ -3226,6 +3323,20 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     }
   }
 
+  @GuiContextAction(
+      id = "pipeline-graph-zzz-auto-layout",
+      parentId = HopGuiPipelineContext.CONTEXT_ID,
+      type = GuiActionType.Modify,
+      name = "i18n::HopGuiPipelineGraph.ContextualAction.AutoLayout.Name",
+      tooltip = 
"i18n::HopGuiPipelineGraph.ContextualAction.AutoLayout.Tooltip",
+      image = "ui/images/auto-layout.svg",
+      category = 
"i18n::HopGuiPipelineGraph.ContextualAction.Category.Basic.Text",
+      categoryOrder = "1",
+      keywords = {"auto", "layout", "arrange", "align", "tidy", "sugiyama"})
+  public void autoLayout(HopGuiPipelineContext context) {
+    applyAutoLayout(false);
+  }
+
   @GuiContextAction(
       id = "pipeline-graph-transform-10100-create-note",
       parentId = HopGuiPipelineContext.CONTEXT_ID,
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 8fb94e9ad9..7de1bd7f97 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
@@ -149,6 +149,7 @@ import org.apache.hop.workflow.IActionListener;
 import org.apache.hop.workflow.WorkflowExecutionConfiguration;
 import org.apache.hop.workflow.WorkflowHopMeta;
 import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.WorkflowMetaLayout;
 import org.apache.hop.workflow.WorkflowPainter;
 import org.apache.hop.workflow.action.ActionMeta;
 import org.apache.hop.workflow.action.IAction;
@@ -177,6 +178,7 @@ import org.eclipse.swt.widgets.Combo;
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Event;
 import org.eclipse.swt.widgets.Listener;
 import org.eclipse.swt.widgets.Shell;
 import org.eclipse.swt.widgets.ToolBar;
@@ -219,6 +221,8 @@ public class HopGuiWorkflowGraph extends HopGuiAbstractGraph
 
   public static final String TOOLBAR_ITEM_ZOOM_TO_FIT =
       "HopGuiWorkflowGraph-ToolBar-10530-Zoom-To-Fit";
+  public static final String TOOLBAR_ITEM_AUTO_LAYOUT =
+      "HopGuiWorkflowGraph-ToolBar-10535-Auto-Layout";
 
   public static final String TOOLBAR_ITEM_DESIGN_ENGINE =
       "HopGuiWorkflowGraph-ToolBar-10550-Design-Engine";
@@ -1729,6 +1733,97 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     super.zoomFitToScreen();
   }
 
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_AUTO_LAYOUT,
+      toolTip = "i18n::HopGuiWorkflowGraph.GuiAction.AutoLayout.Tooltip",
+      type = GuiToolbarElementType.BUTTON,
+      image = "ui/images/auto-layout.svg")
+  public void autoLayoutWorkflow(Event e) {
+    // Click arranges the whole graph; Shift-click arranges only the selected 
actions.
+    boolean selectionOnly = e != null && (e.stateMask & SWT.SHIFT) != 0;
+    applyAutoLayout(selectionOnly);
+  }
+
+  @GuiKeyboardShortcut(control = true, shift = true, key = 'l')
+  @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'l')
+  public void autoLayoutWorkflowShortcut() {
+    applyAutoLayout(false);
+  }
+
+  /**
+   * Auto-layout the workflow, recording all the moves as a single undo action.
+   *
+   * @param selectionOnly when true only the selected actions are arranged 
(anchored to where the
+   *     selection was); otherwise the whole workflow is arranged.
+   */
+  private void applyAutoLayout(boolean selectionOnly) {
+    List<ActionMeta> subset = null;
+    List<ActionMeta> moving;
+    if (selectionOnly) {
+      subset = workflowMeta.getSelectedActions();
+      if (subset == null || subset.size() < 2) {
+        return; // Nothing meaningful to arrange.
+      }
+      moving = new ArrayList<>(subset);
+    } else {
+      int n = workflowMeta.nrActions();
+      if (n == 0) {
+        return;
+      }
+      moving = new ArrayList<>(n);
+      for (int i = 0; i < n; i++) {
+        moving.add(workflowMeta.getAction(i));
+      }
+    }
+
+    // Auto-layout may also reposition notes; capture them so the whole thing 
is one undo step.
+    List<NotePadMeta> notes = new ArrayList<>(workflowMeta.getNotes());
+    Point[] notesBefore = captureNoteLocations(notes);
+
+    Point[] before = captureLocations(moving);
+    WorkflowMetaLayout.layout(workflowMeta, 
PropsUi.getInstance().getAutoLayoutOptions(), subset);
+    Point[] after = captureLocations(moving);
+    Point[] notesAfter = captureNoteLocations(notes);
+
+    // Record notes first, then actions, linked into a single undo action 
(nextAlso).
+    boolean also = false;
+    if (!notes.isEmpty()) {
+      also = true;
+      hopGui.undoDelegate.addUndoPosition(
+          workflowMeta,
+          notes.toArray(new NotePadMeta[0]),
+          workflowMeta.getNoteIndexes(notes),
+          notesBefore,
+          notesAfter,
+          also);
+    }
+    int[] indexes = workflowMeta.getActionIndexes(moving);
+    hopGui.undoDelegate.addUndoPosition(
+        workflowMeta, moving.toArray(new ActionMeta[0]), indexes, before, 
after, also);
+
+    workflowMeta.setChanged();
+    updateGui();
+  }
+
+  private static Point[] captureLocations(List<ActionMeta> actions) {
+    Point[] points = new Point[actions.size()];
+    for (int i = 0; i < actions.size(); i++) {
+      Point p = actions.get(i).getLocation();
+      points[i] = new Point(p.x, p.y);
+    }
+    return points;
+  }
+
+  private static Point[] captureNoteLocations(List<NotePadMeta> notes) {
+    Point[] points = new Point[notes.size()];
+    for (int i = 0; i < notes.size(); i++) {
+      Point p = notes.get(i).getLocation();
+      points[i] = new Point(p.x, p.y);
+    }
+    return points;
+  }
+
   /**
    * Lets the user pick which workflow engine they are designing for. The 
selection persists across
    * Hop restarts and the right-click palette filters out actions the engine 
marks UNSUPPORTED.
@@ -2379,6 +2474,20 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     editProperties(workflowMeta, hopGui, true);
   }
 
+  @GuiContextAction(
+      id = "workflow-graph-zzz-auto-layout",
+      parentId = HopGuiWorkflowContext.CONTEXT_ID,
+      type = GuiActionType.Modify,
+      name = "i18n::HopGuiWorkflowGraph.ContextualAction.AutoLayout.Name",
+      tooltip = 
"i18n::HopGuiWorkflowGraph.ContextualAction.AutoLayout.Tooltip",
+      image = "ui/images/auto-layout.svg",
+      category = 
"i18n::HopGuiWorkflowGraph.ContextualAction.Category.Basic.Text",
+      categoryOrder = "1",
+      keywords = {"auto", "layout", "arrange", "align", "tidy", "sugiyama"})
+  public void autoLayout(HopGuiWorkflowContext context) {
+    applyAutoLayout(false);
+  }
+
   @GuiToolbarElement(
       root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
       id = TOOLBAR_ITEM_EDIT_WORKFLOW,
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
index 4c6df4c1a0..3416959a22 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
@@ -49,6 +49,7 @@ import org.eclipse.swt.layout.FillLayout;
 import org.eclipse.swt.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
 import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.layout.RowLayout;
 import org.eclipse.swt.widgets.Button;
 import org.eclipse.swt.widgets.Canvas;
 import org.eclipse.swt.widgets.Combo;
@@ -115,6 +116,17 @@ public class ConfigGuiOptionsTab {
   private Button wMetricsPanelShowDataVolumeOut;
   private Combo wDefaultLocale;
 
+  private Combo wAutoLayoutDirection;
+  private Text wAutoLayoutLayerSpacing;
+  private Text wAutoLayoutNodeSpacing;
+  private Text wAutoLayoutCrossingIterations;
+  private Button wAutoLayoutMoveNotes;
+
+  /** Auto-layout direction codes, parallel to the localized labels shown in 
the combo. */
+  private static final String[] AUTO_LAYOUT_DIRECTION_CODES = {
+    "LEFT_RIGHT", "RIGHT_LEFT", "TOP_BOTTOM", "BOTTOM_TOP"
+  };
+
   private boolean isReloading = false; // Flag to prevent saving during reload
   private boolean isInitializing = false; // Flag to prevent saving during 
initialization
 
@@ -177,6 +189,17 @@ public class ConfigGuiOptionsTab {
       wGridSize.setText(Integer.toString(props.getCanvasGridSize()));
       wShowCanvasGrid.setSelection(props.isShowCanvasGridEnabled());
 
+      if (wAutoLayoutDirection != null && !wAutoLayoutDirection.isDisposed()) {
+        int directionIndex =
+            Const.indexOfString(props.getAutoLayoutDirection(), 
AUTO_LAYOUT_DIRECTION_CODES);
+        wAutoLayoutDirection.select(Math.max(0, directionIndex));
+        
wAutoLayoutLayerSpacing.setText(Integer.toString(props.getAutoLayoutLayerSpacing()));
+        
wAutoLayoutNodeSpacing.setText(Integer.toString(props.getAutoLayoutNodeSpacing()));
+        wAutoLayoutCrossingIterations.setText(
+            Integer.toString(props.getAutoLayoutCrossingIterations()));
+        wAutoLayoutMoveNotes.setSelection(props.isAutoLayoutMoveNotes());
+      }
+
       wHideViewport.setSelection(!props.isHideViewportEnabled()); // Inverted 
logic
       wUseDoubleClick.setSelection(props.useDoubleClick());
       
wDrawBorderAroundCanvasNames.setSelection(props.isBorderDrawnAroundCanvasNames());
@@ -296,6 +319,34 @@ public class ConfigGuiOptionsTab {
     // Track the last control for vertical positioning
     org.eclipse.swt.widgets.Control lastControl = null;
 
+    // Expand all / Collapse all buttons for the sections below
+    Composite wExpandButtons = new Composite(wLookComp, SWT.NONE);
+    PropsUi.setLook(wExpandButtons);
+    wExpandButtons.setLayout(new RowLayout(SWT.HORIZONTAL));
+    FormData fdExpandButtons = new FormData();
+    fdExpandButtons.left = new FormAttachment(0, 0);
+    fdExpandButtons.top = new FormAttachment(0, margin);
+    wExpandButtons.setLayoutData(fdExpandButtons);
+
+    int iconSize = (int) (PropsUi.getInstance().getZoomFactor() * 24);
+
+    Button wExpandAll = new Button(wExpandButtons, SWT.PUSH);
+    PropsUi.setLook(wExpandAll);
+    wExpandAll.setImage(
+        GuiResource.getInstance().getImage("ui/images/expand-all.svg", 
iconSize, iconSize));
+    wExpandAll.setToolTipText(BaseMessages.getString(PKG, 
"EnterOptionsDialog.ExpandAll.Tooltip"));
+    wExpandAll.addListener(SWT.Selection, e -> 
setAllSectionsExpanded(wLookComp, sLookComp, true));
+
+    Button wCollapseAll = new Button(wExpandButtons, SWT.PUSH);
+    PropsUi.setLook(wCollapseAll);
+    wCollapseAll.setImage(
+        GuiResource.getInstance().getImage("ui/images/collapse-all.svg", 
iconSize, iconSize));
+    wCollapseAll.setToolTipText(
+        BaseMessages.getString(PKG, "EnterOptionsDialog.CollapseAll.Tooltip"));
+    wCollapseAll.addListener(
+        SWT.Selection, e -> setAllSectionsExpanded(wLookComp, sLookComp, 
false));
+    lastControl = wExpandButtons;
+
     // Preferred language - at the top
     org.eclipse.swt.widgets.Control[] defaultLocaleControls =
         createComboField(
@@ -757,6 +808,132 @@ public class ConfigGuiOptionsTab {
 
     lastControl = canvasExpandBar;
 
+    // Auto-layout section - using ExpandBar
+    ExpandBar autoLayoutExpandBar = new ExpandBar(wLookComp, SWT.V_SCROLL);
+    PropsUi.setLook(autoLayoutExpandBar);
+
+    FormData fdAutoLayoutExpandBar = new FormData();
+    fdAutoLayoutExpandBar.left = new FormAttachment(0, 0);
+    fdAutoLayoutExpandBar.right = new FormAttachment(100, 0);
+    fdAutoLayoutExpandBar.top = new FormAttachment(lastControl, 2 * margin);
+    autoLayoutExpandBar.setLayoutData(fdAutoLayoutExpandBar);
+
+    Composite autoLayoutContent = new Composite(autoLayoutExpandBar, SWT.NONE);
+    PropsUi.setLook(autoLayoutContent);
+    FormLayout autoLayoutLayout = new FormLayout();
+    autoLayoutLayout.marginWidth = PropsUi.getFormMargin();
+    autoLayoutLayout.marginHeight = PropsUi.getFormMargin();
+    autoLayoutContent.setLayout(autoLayoutLayout);
+
+    org.eclipse.swt.widgets.Control lastAutoLayoutControl = null;
+
+    // Direction
+    String[] directionLabels = {
+      BaseMessages.getString(PKG, 
"EnterOptionsDialog.AutoLayout.Direction.LeftRight"),
+      BaseMessages.getString(PKG, 
"EnterOptionsDialog.AutoLayout.Direction.RightLeft"),
+      BaseMessages.getString(PKG, 
"EnterOptionsDialog.AutoLayout.Direction.TopBottom"),
+      BaseMessages.getString(PKG, 
"EnterOptionsDialog.AutoLayout.Direction.BottomTop")
+    };
+    org.eclipse.swt.widgets.Control[] directionControls =
+        createComboField(
+            autoLayoutContent,
+            "EnterOptionsDialog.AutoLayout.Direction.Label",
+            "EnterOptionsDialog.AutoLayout.Direction.ToolTip",
+            directionLabels,
+            lastAutoLayoutControl,
+            margin);
+    wAutoLayoutDirection = (Combo) directionControls[1];
+    int directionIndex =
+        Const.indexOfString(props.getAutoLayoutDirection(), 
AUTO_LAYOUT_DIRECTION_CODES);
+    wAutoLayoutDirection.select(Math.max(0, directionIndex));
+    lastAutoLayoutControl = wAutoLayoutDirection;
+
+    // Layer spacing
+    org.eclipse.swt.widgets.Control[] layerSpacingControls =
+        createTextField(
+            autoLayoutContent,
+            "EnterOptionsDialog.AutoLayout.LayerSpacing.Label",
+            "EnterOptionsDialog.AutoLayout.LayerSpacing.ToolTip",
+            Integer.toString(props.getAutoLayoutLayerSpacing()),
+            lastAutoLayoutControl,
+            margin);
+    wAutoLayoutLayerSpacing = (Text) layerSpacingControls[1];
+    wAutoLayoutLayerSpacing.setMessage(
+        BaseMessages.getString(PKG, ENTER_OPTIONS_DIALOG_ENTER_NUMBER_HINT));
+    wAutoLayoutLayerSpacing.addListener(SWT.Verify, this::verifyNumber);
+    lastAutoLayoutControl = wAutoLayoutLayerSpacing;
+
+    // Node spacing
+    org.eclipse.swt.widgets.Control[] nodeSpacingControls =
+        createTextField(
+            autoLayoutContent,
+            "EnterOptionsDialog.AutoLayout.NodeSpacing.Label",
+            "EnterOptionsDialog.AutoLayout.NodeSpacing.ToolTip",
+            Integer.toString(props.getAutoLayoutNodeSpacing()),
+            lastAutoLayoutControl,
+            margin);
+    wAutoLayoutNodeSpacing = (Text) nodeSpacingControls[1];
+    wAutoLayoutNodeSpacing.setMessage(
+        BaseMessages.getString(PKG, ENTER_OPTIONS_DIALOG_ENTER_NUMBER_HINT));
+    wAutoLayoutNodeSpacing.addListener(SWT.Verify, this::verifyNumber);
+    lastAutoLayoutControl = wAutoLayoutNodeSpacing;
+
+    // Crossing-reduction iterations
+    org.eclipse.swt.widgets.Control[] iterationsControls =
+        createTextField(
+            autoLayoutContent,
+            "EnterOptionsDialog.AutoLayout.CrossingIterations.Label",
+            "EnterOptionsDialog.AutoLayout.CrossingIterations.ToolTip",
+            Integer.toString(props.getAutoLayoutCrossingIterations()),
+            lastAutoLayoutControl,
+            margin);
+    wAutoLayoutCrossingIterations = (Text) iterationsControls[1];
+    wAutoLayoutCrossingIterations.setMessage(
+        BaseMessages.getString(PKG, ENTER_OPTIONS_DIALOG_ENTER_NUMBER_HINT));
+    wAutoLayoutCrossingIterations.addListener(SWT.Verify, this::verifyNumber);
+    lastAutoLayoutControl = wAutoLayoutCrossingIterations;
+
+    // Move notes with their nearest node
+    wAutoLayoutMoveNotes =
+        createCheckbox(
+            autoLayoutContent,
+            "EnterOptionsDialog.AutoLayout.MoveNotes.Label",
+            "EnterOptionsDialog.AutoLayout.MoveNotes.ToolTip",
+            props.isAutoLayoutMoveNotes(),
+            lastAutoLayoutControl,
+            margin);
+
+    ExpandItem autoLayoutItem = new ExpandItem(autoLayoutExpandBar, SWT.NONE);
+    autoLayoutItem.setText(BaseMessages.getString(PKG, 
"EnterOptionsDialog.Section.AutoLayout"));
+    autoLayoutItem.setControl(autoLayoutContent);
+    autoLayoutItem.setHeight(autoLayoutContent.computeSize(SWT.DEFAULT, 
SWT.DEFAULT).y);
+    autoLayoutItem.setExpanded(true);
+
+    autoLayoutExpandBar.addListener(
+        SWT.Expand,
+        e ->
+            Display.getDefault()
+                .asyncExec(
+                    () -> {
+                      if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
+                        wLookComp.layout();
+                        
sLookComp.setMinHeight(wLookComp.computeSize(SWT.DEFAULT, SWT.DEFAULT).y);
+                      }
+                    }));
+    autoLayoutExpandBar.addListener(
+        SWT.Collapse,
+        e ->
+            Display.getDefault()
+                .asyncExec(
+                    () -> {
+                      if (!wLookComp.isDisposed() && !sLookComp.isDisposed()) {
+                        wLookComp.layout();
+                        
sLookComp.setMinHeight(wLookComp.computeSize(SWT.DEFAULT, SWT.DEFAULT).y);
+                      }
+                    }));
+
+    lastControl = autoLayoutExpandBar;
+
     // Tables & grids section - using ExpandBar
     ExpandBar tablesExpandBar = new ExpandBar(wLookComp, SWT.V_SCROLL);
     PropsUi.setLook(tablesExpandBar);
@@ -1184,6 +1361,19 @@ public class ConfigGuiOptionsTab {
     props.setLineWidth(Const.toInt(wLineWidth.getText(), 
props.getLineWidth()));
     props.setMiddlePct(Const.toInt(wMiddlePct.getText(), 
props.getMiddlePct()));
     props.setCanvasGridSize(Const.toInt(wGridSize.getText(), 1));
+    int directionIndex = wAutoLayoutDirection.getSelectionIndex();
+    if (directionIndex < 0 || directionIndex >= 
AUTO_LAYOUT_DIRECTION_CODES.length) {
+      directionIndex = 0;
+    }
+    props.setAutoLayoutDirection(AUTO_LAYOUT_DIRECTION_CODES[directionIndex]);
+    props.setAutoLayoutLayerSpacing(
+        Const.toInt(wAutoLayoutLayerSpacing.getText(), 
props.getAutoLayoutLayerSpacing()));
+    props.setAutoLayoutNodeSpacing(
+        Const.toInt(wAutoLayoutNodeSpacing.getText(), 
props.getAutoLayoutNodeSpacing()));
+    props.setAutoLayoutCrossingIterations(
+        Const.toInt(
+            wAutoLayoutCrossingIterations.getText(), 
props.getAutoLayoutCrossingIterations()));
+    props.setAutoLayoutMoveNotes(wAutoLayoutMoveNotes.getSelection());
     
props.setGlobalZoomFactor(Const.toDouble(wGlobalZoom.getText().replace("%", 
""), 100) / 100);
     props.setShowCanvasGridEnabled(wShowCanvasGrid.getSelection());
     props.setHideViewportEnabled(
@@ -1296,6 +1486,29 @@ public class ConfigGuiOptionsTab {
    * @param margin The margin to use
    * @return An array containing [Label, Text] controls
    */
+  /** Expand or collapse every section (ExpandBar) on the Look &amp; Feel tab 
and relayout. */
+  private void setAllSectionsExpanded(
+      Composite lookComp, ScrolledComposite scrolled, boolean expanded) {
+    for (Control c : lookComp.getChildren()) {
+      if (c instanceof ExpandBar) {
+        for (ExpandItem item : ((ExpandBar) c).getItems()) {
+          item.setExpanded(expanded);
+        }
+      }
+    }
+    lookComp.layout();
+    scrolled.setMinHeight(lookComp.computeSize(SWT.DEFAULT, SWT.DEFAULT).y);
+  }
+
+  /** Reject any change that would make the text field contain something other 
than digits. */
+  private void verifyNumber(Event e) {
+    String currentText = ((Text) e.widget).getText();
+    String newText = currentText.substring(0, e.start) + e.text + 
currentText.substring(e.end);
+    if (!newText.isEmpty() && !newText.matches("\\d+")) {
+      e.doit = false;
+    }
+  }
+
   private org.eclipse.swt.widgets.Control[] createTextField(
       Composite parent,
       String labelKey,
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
index ddd2ca941a..9568d8e2f0 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
@@ -88,6 +88,21 @@ EnterOptionsDialog.Section.Appearance=Appearance
 EnterOptionsDialog.Section.GeneralAppearance=General appearance
 EnterOptionsDialog.Section.PipelineWorkflowCanvas=Pipeline and Workflow canvas
 EnterOptionsDialog.Section.TablesGrids=Tables and grids
+EnterOptionsDialog.Section.AutoLayout=Auto-layout
+EnterOptionsDialog.AutoLayout.Direction.Label=Layout direction
+EnterOptionsDialog.AutoLayout.Direction.ToolTip=The direction in which the 
graph flows from start to end when arranging transforms or actions
+EnterOptionsDialog.AutoLayout.Direction.LeftRight=Left to right
+EnterOptionsDialog.AutoLayout.Direction.RightLeft=Right to left
+EnterOptionsDialog.AutoLayout.Direction.TopBottom=Top to bottom
+EnterOptionsDialog.AutoLayout.Direction.BottomTop=Bottom to top
+EnterOptionsDialog.AutoLayout.LayerSpacing.Label=Layer spacing
+EnterOptionsDialog.AutoLayout.LayerSpacing.ToolTip=Distance between 
consecutive layers, along the flow direction
+EnterOptionsDialog.AutoLayout.NodeSpacing.Label=Node spacing
+EnterOptionsDialog.AutoLayout.NodeSpacing.ToolTip=Distance between nodes 
within the same layer, perpendicular to the flow direction
+EnterOptionsDialog.AutoLayout.CrossingIterations.Label=Crossing-reduction 
iterations
+EnterOptionsDialog.AutoLayout.CrossingIterations.ToolTip=Number of barycenter 
sweeps used to reduce edge crossings (higher is tidier but slower on large 
graphs)
+EnterOptionsDialog.AutoLayout.MoveNotes.Label=Move notes with nearest node
+EnterOptionsDialog.AutoLayout.MoveNotes.ToolTip=When arranging, move each note 
along with the transform or action it sits closest to. Notes that aren't close 
to any node are left in place.
 EnterOptionsDialog.Section.MetricsPanel=Metrics panel
 EnterOptionsDialog.MetricsPanel.ShowUnits.Label=Show units in cells
 EnterOptionsDialog.MetricsPanel.ShowUnits.ToolTip=When enabled, units (e.g. r, 
h:m:s, rows/s) are shown in the pipeline metrics grid cells.
@@ -112,6 +127,8 @@ EnterOptionsDialog.AutoSplitHops.Label=Automatically split 
hops
 EnterOptionsDialog.AutoSplitHops.Tooltip=If a transform is drawn on a hop the 
hop can be split\nso that the new transform lays between the two original 
ones.\nIf you activate this option this will happen automatically, otherwise a 
confirmation dialog is shown.
 EnterOptionsDialog.Button.Edit=Change
 EnterOptionsDialog.Button.Edit.Tooltip=Edits the option
+EnterOptionsDialog.ExpandAll.Tooltip=Expand all sections
+EnterOptionsDialog.CollapseAll.Tooltip=Collapse all sections
 EnterOptionsDialog.Button.Reset=Reset
 EnterOptionsDialog.Button.Reset.Tooltip=Resets this option to the default value
 EnterOptionsDialog.ResetConfirmations.Label=Reset all to enabled (Includes 
Action/Transfrom warnings)
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/file/pipeline/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/file/pipeline/messages/messages_en_US.properties
index 52de2828f3..621354a144 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/file/pipeline/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/file/pipeline/messages/messages_en_US.properties
@@ -20,10 +20,13 @@ 
HopGuiPipelineGraph.ContextualAction.Category.Basic.Text=Basic
 HopGuiPipelineGraph.ContextualAction.Category.Bulk.Text=Bulk
 HopGuiPipelineGraph.ContextualAction.Category.Preview.Text=Preview & debug
 HopGuiPipelineGraph.ContextualAction.Category.Routing.Text=Data routing
+HopGuiPipelineGraph.ContextualAction.AutoLayout.Name=Arrange transforms
+HopGuiPipelineGraph.ContextualAction.AutoLayout.Tooltip=Auto-layout: arrange 
the transforms left-to-right with no overlaps
 HopGuiPipelineGraph.ContextualAction.NavigateToExecutionInfo.Text=To execution 
info
 HopGuiPipelineGraph.ContextualAction.NavigateToExecutionInfo.Tooltip=Navigate 
to the execution information for this pipeline
 HopGuiPipelineGraph.DistributionMethodDialog.Header=Select distribution method
 HopGuiPipelineGraph.DistributionMethodDialog.Text=Please select the row 
distribution method:
+HopGuiPipelineGraph.GuiAction.AutoLayout.Tooltip=Arrange transforms 
(auto-layout). Click for the whole pipeline, Shift-click to arrange only the 
selection (Ctrl/Cmd+Shift+L).
 HopGuiPipelineGraph.GuiAction.Zoom100.Tooltip=Zoom to 100%
 HopGuiPipelineGraph.GuiAction.ZoomFitToScreen.Tooltip=Zoom to fit screen size
 HopGuiPipelineGraph.GuiAction.ZoomIn.Tooltip=Zoom in 10%
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
index d4464d9f26..e1e59c8624 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/file/workflow/messages/messages_en_US.properties
@@ -55,6 +55,8 @@ 
HopGuiWorkflowGraph.ContextualAction.EditActionDescription.Text=Edit action desc
 HopGuiWorkflowGraph.ContextualAction.EditActionDescription.Tooltip=Modify the 
action description
 HopGuiWorkflowGraph.ContextualAction.EditNote.Text=Edit
 HopGuiWorkflowGraph.ContextualAction.EditNote.Tooltip=Edit the note
+HopGuiWorkflowGraph.ContextualAction.AutoLayout.Name=Arrange actions
+HopGuiWorkflowGraph.ContextualAction.AutoLayout.Tooltip=Auto-layout: arrange 
the actions left-to-right with no overlaps
 HopGuiWorkflowGraph.ContextualAction.EditWorkflow.Text=Edit workflow
 HopGuiWorkflowGraph.ContextualAction.EditWorkflow.Tooltip=Edit the workflow 
properties
 HopGuiWorkflowGraph.ContextualAction.EnableBetweenSelectedActions.Text=Enable 
hops between selection
@@ -93,6 +95,7 @@ HopGuiWorkflowGraph.ErrorDialog.FileNotLoaded.Header=Error
 HopGuiWorkflowGraph.ErrorDialog.FileNotLoaded.Message=The referenced file 
couldn''t be loaded
 HopGuiWorkflowGraph.ErrorDialog.WorkflowDrawing.Header=Error
 HopGuiWorkflowGraph.ErrorDialog.WorkflowDrawing.Message=Error drawing workflow
+HopGuiWorkflowGraph.GuiAction.AutoLayout.Tooltip=Arrange actions 
(auto-layout). Click for the whole workflow, Shift-click to arrange only the 
selection (Ctrl/Cmd+Shift+L).
 HopGuiWorkflowGraph.GuiAction.Zoom100.Tooltip=Zoom to 100%
 HopGuiWorkflowGraph.GuiAction.ZoomFitToScreen.Tooltip=Zoom to fit screen size
 HopGuiWorkflowGraph.GuiAction.ZoomIn.Tooltip=Zoom in 10%

Reply via email to