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 & 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%