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 8460b49c89 force recalculation of shell to place widgets correctly, 
fixes #7689 (#7690)
8460b49c89 is described below

commit 8460b49c89780e17b2723210612a28e493b035a2
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Jul 30 14:52:58 2026 +0200

    force recalculation of shell to place widgets correctly, fixes #7689 (#7690)
---
 .../hop/ui/core/gui/WindowPropertyLayoutTest.java  | 136 +++++++++++++
 .../vfs/HopVfsFileDialogToolbarLayoutTest.java     | 218 +++++++++++++++++++++
 .../org/apache/hop/ui/core/gui/WindowProperty.java |  43 ++++
 3 files changed, 397 insertions(+)

diff --git 
a/rcp/src/test/java/org/apache/hop/ui/core/gui/WindowPropertyLayoutTest.java 
b/rcp/src/test/java/org/apache/hop/ui/core/gui/WindowPropertyLayoutTest.java
new file mode 100644
index 0000000000..04932d4f3b
--- /dev/null
+++ b/rcp/src/test/java/org/apache/hop/ui/core/gui/WindowPropertyLayoutTest.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.gui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.ToolBar;
+import org.eclipse.swt.widgets.ToolItem;
+import org.eclipse.swt.widgets.Tree;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Restoring a remembered geometry with {@link WindowProperty#setShell(Shell)} 
has to leave the
+ * widgets where their layout puts them.
+ *
+ * <p>This is the shape every Hop perspective with a toolbar over a tree uses 
- metadata, git and
+ * execution information all build a {@link SashForm} pane holding a toolbar 
above a tree, and they
+ * build it before the main shell is opened. {@code setShell} sets the shell's 
*outer* bounds, so
+ * the window trim is only subtracted from the client area once the shell is 
on screen; without a
+ * re-layout at that point every child of the pane stays shifted up by the 
trim height and the
+ * toolbar is clipped against the top of the panel until the user resizes the 
window.
+ */
+@Tag("uitest")
+class WindowPropertyLayoutTest extends SwtBotTestBase {
+
+  private static final String SHELL_NAME = "WindowPropertyLayoutTest";
+  private static final int REMEMBERED_WIDTH = 900;
+  private static final int REMEMBERED_HEIGHT = 600;
+
+  @Test
+  void toolbarInASashPaneIsLaidOutAfterRestoringTheGeometry() {
+    AtomicReference<ToolBar> toolBar = new AtomicReference<>();
+    AtomicReference<Tree> tree = new AtomicReference<>();
+
+    withScene(
+        shell -> {
+          shell.setText(SHELL_NAME);
+          buildPerspectiveLikeScene(shell, toolBar, tree);
+          // What HopGui and every remembering dialog do just before open().
+          new WindowProperty(SHELL_NAME, false, 100, 100, REMEMBERED_WIDTH, 
REMEMBERED_HEIGHT)
+              .setShell(shell);
+        },
+        bot -> {
+          // Deliberately no resize and no explicit layout: this is what the 
user sees on open.
+          bot.sleep(400);
+
+          org.eclipse.swt.graphics.Rectangle toolBarBounds = 
syncBounds(toolBar.get());
+          org.eclipse.swt.graphics.Rectangle treeBounds = 
syncBounds(tree.get());
+
+          assertEquals(
+              0,
+              toolBarBounds.y,
+              "The toolbar should sit at the top of its panel, not above it: " 
+ toolBarBounds);
+          assertTrue(
+              treeBounds.y >= toolBarBounds.y + toolBarBounds.height,
+              "The tree should start below the toolbar, but tree="
+                  + treeBounds
+                  + " toolbar="
+                  + toolBarBounds);
+        });
+  }
+
+  /** Toolbar over a tree inside a SashForm pane: the layout the perspectives 
use. */
+  private void buildPerspectiveLikeScene(
+      Shell shell, AtomicReference<ToolBar> toolBarRef, AtomicReference<Tree> 
treeRef) {
+    shell.setLayout(new FormLayout());
+
+    SashForm sash = new SashForm(shell, SWT.HORIZONTAL);
+    FormData fdSash = new FormData();
+    fdSash.left = new FormAttachment(0, 0);
+    fdSash.top = new FormAttachment(0, 0);
+    fdSash.right = new FormAttachment(100, 0);
+    fdSash.bottom = new FormAttachment(100, 0);
+    sash.setLayoutData(fdSash);
+
+    Composite pane = new Composite(sash, SWT.BORDER);
+    pane.setLayout(new FormLayout());
+
+    ToolBar toolBar = new ToolBar(pane, SWT.WRAP | SWT.LEFT | SWT.HORIZONTAL);
+    for (int i = 0; i < 5; i++) {
+      new ToolItem(toolBar, SWT.PUSH).setText("i" + i);
+    }
+    FormData fdToolBar = new FormData();
+    fdToolBar.left = new FormAttachment(0, 0);
+    fdToolBar.top = new FormAttachment(0, 0);
+    fdToolBar.right = new FormAttachment(100, 0);
+    toolBar.setLayoutData(fdToolBar);
+    toolBar.pack();
+    toolBarRef.set(toolBar);
+
+    Tree tree = new Tree(pane, SWT.SINGLE | SWT.H_SCROLL | SWT.V_SCROLL);
+    FormData fdTree = new FormData();
+    fdTree.left = new FormAttachment(0, 0);
+    fdTree.top = new FormAttachment(toolBar, 0);
+    fdTree.right = new FormAttachment(100, 0);
+    fdTree.bottom = new FormAttachment(100, 0);
+    tree.setLayoutData(fdTree);
+    treeRef.set(tree);
+
+    // The editor side of the perspective.
+    new Composite(sash, SWT.NONE).setLayout(new FormLayout());
+    sash.setWeights(25, 75);
+  }
+
+  private org.eclipse.swt.graphics.Rectangle 
syncBounds(org.eclipse.swt.widgets.Control control) {
+    AtomicReference<org.eclipse.swt.graphics.Rectangle> bounds = new 
AtomicReference<>();
+    control.getDisplay().syncExec(() -> bounds.set(control.getBounds()));
+    return bounds.get();
+  }
+}
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogToolbarLayoutTest.java
 
b/rcp/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogToolbarLayoutTest.java
new file mode 100644
index 0000000000..6e534c83d0
--- /dev/null
+++ 
b/rcp/src/test/java/org/apache/hop/ui/core/vfs/HopVfsFileDialogToolbarLayoutTest.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.core.vfs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.hopgui.HopGuiEnvironment;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.ToolBar;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotShell;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * The toolbars of the Open file dialog have to sit where their layout puts 
them the moment the
+ * dialog appears, without the user resizing the window first.
+ *
+ * <p>Restoring a remembered window geometry used to break that: the widgets 
were laid out while the
+ * shell still had its pre-open size, so once the window trim (title bar) was 
subtracted from the
+ * client area, everything inside a {@code SashForm} pane ended up shifted off 
the top of the panel
+ * by the trim height - a toolbar clipped to a sliver above the file browser.
+ */
+@Tag("uitest")
+class HopVfsFileDialogToolbarLayoutTest extends SwtBotTestBase {
+
+  private static final int REMEMBERED_WIDTH = 1000;
+  private static final int REMEMBERED_HEIGHT = 600;
+
+  @TempDir private Path folder;
+
+  @BeforeAll
+  static void registerGuiPlugins() throws Exception {
+    // The toolbars are built from @GuiToolbarElement annotations, which only 
reach the GuiRegistry
+    // once the GUI plugin types have been scanned.
+    HopGuiEnvironment.init();
+  }
+
+  @Test
+  void toolbarsAreLaidOutWhenTheDialogRemembersItsGeometry() throws Exception {
+    // Mimic a dialog that has been opened before: 
BaseTransformDialog.setSize() then restores the
+    // geometry through WindowProperty instead of packing the shell.
+    PropsUi.getInstance()
+        .setSessionScreen(
+            new WindowProperty(
+                openFileTitle(), false, 100, 100, REMEMBERED_WIDTH, 
REMEMBERED_HEIGHT));
+
+    List<String> problems = openDialogAndCheckToolbars();
+
+    assertTrue(problems.isEmpty(), "Toolbars are misplaced on open: " + 
problems);
+  }
+
+  @Test
+  void toolbarsAreLaidOutWhenTheDialogHasNoRememberedGeometry() throws 
Exception {
+    List<String> problems = openDialogAndCheckToolbars();
+
+    assertTrue(problems.isEmpty(), "Toolbars are misplaced on open: " + 
problems);
+  }
+
+  /**
+   * Opens the real dialog and, without touching the window, reports every 
toolbar that is not fully
+   * inside its parent's client area.
+   */
+  private List<String> openDialogAndCheckToolbars() throws IOException {
+    createPipelineFile("one.hpl");
+
+    AtomicReference<List<String>> problems = new AtomicReference<>();
+    withDialog(
+        parent -> presentDialog(parent),
+        bot -> {
+          SWTBotShell shell = bot.shell(openFileTitle());
+          Shell swtShell = shell.widget;
+          problems.set(collectMisplacedToolbars(swtShell));
+          shell.bot().button(buttonLabel("System.Button.Cancel")).click();
+        });
+    return problems.get();
+  }
+
+  private List<String> collectMisplacedToolbars(Shell shell) {
+    AtomicReference<List<String>> result = new AtomicReference<>();
+    shell.getDisplay().syncExec(() -> result.set(checkToolbars(shell)));
+    return result.get();
+  }
+
+  private List<String> checkToolbars(Shell shell) {
+    List<String> problems = new ArrayList<>();
+    List<ToolBar> toolBars = new ArrayList<>();
+    collectToolBars(shell, toolBars);
+    assertFalse(toolBars.isEmpty(), "The dialog should have toolbars to 
check");
+
+    for (ToolBar toolBar : toolBars) {
+      Rectangle bounds = toolBar.getBounds();
+      Rectangle parentArea = toolBar.getParent().getClientArea();
+      if (bounds.y < 0 || bounds.y + bounds.height > parentArea.height) {
+        problems.add(
+            String.format(
+                "toolbar with %d item(s) at %s does not fit its parent's 
client area %s",
+                toolBar.getItemCount(), bounds, parentArea));
+      }
+      // A toolbar sitting in the right place is useless if its items are 
clipped out of it.
+      for (var item : toolBar.getItems()) {
+        Rectangle itemBounds = item.getBounds();
+        if (itemBounds.y < 0 || itemBounds.y + itemBounds.height > 
bounds.height) {
+          problems.add(
+              String.format("item at %s is clipped by its toolbar %s", 
itemBounds, bounds));
+        }
+      }
+    }
+    return problems;
+  }
+
+  /**
+   * Every toolbar row also has to keep its distance from the widget below it, 
which is what the
+   * clipped-toolbar screenshot really shows.
+   */
+  @Test
+  void theFileBrowserStartsBelowItsToolbar() throws Exception {
+    PropsUi.getInstance()
+        .setSessionScreen(
+            new WindowProperty(
+                openFileTitle(), false, 100, 100, REMEMBERED_WIDTH, 
REMEMBERED_HEIGHT));
+    createPipelineFile("one.hpl");
+
+    AtomicReference<Integer> toolbarTop = new AtomicReference<>();
+    withDialog(
+        parent -> presentDialog(parent),
+        bot -> {
+          SWTBotShell shell = bot.shell(openFileTitle());
+          Shell swtShell = shell.widget;
+          swtShell
+              .getDisplay()
+              .syncExec(
+                  () -> {
+                    List<ToolBar> toolBars = new ArrayList<>();
+                    collectToolBars(swtShell, toolBars);
+                    // The browser toolbar is the one sharing its parent with 
the search field.
+                    int top =
+                        toolBars.stream()
+                            .mapToInt(toolBar -> toolBar.getBounds().y)
+                            .min()
+                            .orElse(Integer.MIN_VALUE);
+                    toolbarTop.set(top);
+                  });
+          shell.bot().button(buttonLabel("System.Button.Cancel")).click();
+        });
+
+    assertEquals(0, toolbarTop.get(), "No toolbar should be pushed above the 
top of its panel");
+  }
+
+  private void presentDialog(Shell parent) {
+    try {
+      BaseDialog.presentFileDialog(
+          false,
+          parent,
+          null,
+          new Variables(),
+          HopVfs.getFileObject(folder.toString()),
+          new String[] {"*.hpl"},
+          new String[] {"Pipelines"},
+          false);
+    } catch (Exception e) {
+      throw new IllegalStateException("Error opening the file dialog", e);
+    }
+  }
+
+  private void collectToolBars(Composite parent, List<ToolBar> into) {
+    for (Control child : parent.getChildren()) {
+      if (child instanceof ToolBar toolBar) {
+        into.add(toolBar);
+      }
+      if (child instanceof Composite composite) {
+        collectToolBars(composite, into);
+      }
+    }
+  }
+
+  private static String openFileTitle() {
+    return BaseMessages.getString(BaseDialog.class, "BaseDialog.OpenFile");
+  }
+
+  private void createPipelineFile(String name) throws IOException {
+    Files.writeString(folder.resolve(name), "<pipeline/>");
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/WindowProperty.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/WindowProperty.java
index 68a478b37e..200d1b6e82 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/WindowProperty.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/WindowProperty.java
@@ -20,9 +20,12 @@ package org.apache.hop.ui.core.gui;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
+import org.eclipse.swt.SWT;
 import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.graphics.Rectangle;
 import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Listener;
 import org.eclipse.swt.widgets.Monitor;
 import org.eclipse.swt.widgets.Shell;
 
@@ -197,6 +200,46 @@ public class WindowProperty {
 
       shell.setBounds(resizedRect);
     }
+
+    relayoutOnShow(shell);
+  }
+
+  /**
+   * Re-run the shell's layout once it is actually on screen.
+   *
+   * <p>Restoring a remembered geometry means the widgets are built - and laid 
out - while the shell
+   * still has its pre-open size. {@link Shell#setBounds(Rectangle)} sets the 
*outer* bounds, so the
+   * client area only loses the window trim (title bar) when the shell is 
shown. Composites whose
+   * bounds are handed to them by a parent layout that does not re-run - a 
{@link
+   * org.eclipse.swt.custom.SashForm} pane is the common case - then keep 
child positions that are
+   * off by the trim height, which shows up as a toolbar clipped against the 
top of the panel until
+   * the user resizes the window by hand.
+   *
+   * <p>A single recursive layout on the first show costs nothing noticeable 
and puts every child
+   * where the freshly-sized shell says it belongs.
+   */
+  private void relayoutOnShow(Shell shell) {
+    shell.addListener(
+        SWT.Show,
+        new Listener() {
+          @Override
+          public void handleEvent(Event event) {
+            shell.removeListener(SWT.Show, this);
+            // Asynchronous: on the first show the platform is still settling 
the window frame, so
+            // the client area is only final once the pending events have been 
handled.
+            shell.getDisplay().asyncExec(() -> layoutIfAlive(shell));
+          }
+        });
+    // A shell that is already up gets its layout straight away: no Show event 
is coming.
+    if (shell.isVisible()) {
+      layoutIfAlive(shell);
+    }
+  }
+
+  private static void layoutIfAlive(Shell shell) {
+    if (!shell.isDisposed()) {
+      shell.layout(true, true);
+    }
   }
 
   /**

Reply via email to