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 c93b31d822 Issue #7890 : Type-to-search filter for the unit test 
toolbar combo (#8014)
c93b31d822 is described below

commit c93b31d8228815da71af9bc6495cec8a9a04a84d
Author: Matt Casters <[email protected]>
AuthorDate: Wed Aug 19 15:51:56 2026 +0200

    Issue #7890 : Type-to-search filter for the unit test toolbar combo (#8014)
    
    Clicking the combo selects the current test name. Typing replaces it
    and shows matching unit tests in a popup under the combo.
---
 .../apache/hop/testing/gui/TestingGuiPlugin.java   |  31 ++
 .../testing/gui/messages/messages_en_US.properties |   2 +-
 .../hop/ui/core/widget/ComboFilterPopup.java       | 438 +++++++++++++++++++++
 .../hop/ui/core/widget/ComboFilterPopupTest.java   |  81 ++++
 4 files changed, 551 insertions(+), 1 deletion(-)

diff --git 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/gui/TestingGuiPlugin.java
 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/gui/TestingGuiPlugin.java
index 0dd93ad806..61437fb05d 100644
--- 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/gui/TestingGuiPlugin.java
+++ 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/gui/TestingGuiPlugin.java
@@ -18,6 +18,7 @@
 package org.apache.hop.testing.gui;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
@@ -74,6 +75,7 @@ import org.apache.hop.ui.core.dialog.MessageBox;
 import org.apache.hop.ui.core.dialog.SelectRowDialog;
 import org.apache.hop.ui.core.metadata.MetadataManager;
 import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.ComboFilterPopup;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
@@ -1094,6 +1096,29 @@ public class TestingGuiPlugin {
     return null;
   }
 
+  /**
+   * Clicking the combo selects the current test name so the next keystroke 
replaces it. Typing then
+   * filters the list in a popup under the combo (issue #7890).
+   */
+  private void installUnitTestComboSearch(Combo combo) {
+    if (combo == null || combo.isDisposed()) {
+      return;
+    }
+    ComboFilterPopup.attach(
+        combo, () -> Arrays.asList(combo.getItems()), 
this::applyFilteredUnitTest);
+  }
+
+  private void applyFilteredUnitTest(String testName) {
+    Combo combo = getUnitTestsCombo();
+    if (combo == null || combo.isDisposed()) {
+      return;
+    }
+    if (!Const.NVL(testName, "").equals(combo.getText())) {
+      combo.setText(Const.NVL(testName, ""));
+    }
+    selectUnitTest();
+  }
+
   /**
    * Enable or disable the unit test buttons (Edit, Detach, Delete) based on 
whether a unit test is
    * selected.
@@ -1110,6 +1135,7 @@ public class TestingGuiPlugin {
     }
 
     Combo combo = getUnitTestsCombo();
+    installUnitTestComboSearch(combo);
     boolean hasSelection = combo != null && 
!StringUtils.isEmpty(combo.getText());
 
     if (log.isDebug()) {
@@ -1290,6 +1316,11 @@ public class TestingGuiPlugin {
       if (!Utils.isEmpty(testName)) {
         PipelineUnitTest unitTest = testSerializer.load(testName);
         if (unitTest == null) {
+          ComboFilterPopup filter = ComboFilterPopup.get(combo);
+          if (filter != null && filter.isPopupOpen()) {
+            // Still typing a search; do not treat the filter text as a 
missing test.
+            return;
+          }
           throw new HopException(
               BaseMessages.getString(
                   PKG, 
"TestingGuiPlugin.ToolbarElement.GetUnitTestList.Exception", testName));
diff --git 
a/plugins/misc/testing/src/main/resources/org/apache/hop/testing/gui/messages/messages_en_US.properties
 
b/plugins/misc/testing/src/main/resources/org/apache/hop/testing/gui/messages/messages_en_US.properties
index c2490f9a66..7bb979dd90 100644
--- 
a/plugins/misc/testing/src/main/resources/org/apache/hop/testing/gui/messages/messages_en_US.properties
+++ 
b/plugins/misc/testing/src/main/resources/org/apache/hop/testing/gui/messages/messages_en_US.properties
@@ -87,7 +87,7 @@ TestingGuiPlugin.ToolbarElement.Detach.Error.Message=Error 
while detaching unit
 TestingGuiPlugin.ToolbarElement.GetUnitTestList.Error.Header=Error
 TestingGuiPlugin.ToolbarElement.GetUnitTestList.Error.Message=Error selecting 
a new pipeline unit test
 TestingGuiPlugin.ToolbarElement.GetUnitTestList.Exception="Unit test ''{0}'' 
could not be found (deleted)?
-TestingGuiPlugin.ToolbarElement.GetUnitTestList.Tooltip=Select the Unit test
+TestingGuiPlugin.ToolbarElement.GetUnitTestList.Tooltip=Select the unit test. 
Click to highlight the name, then type to search.
 TestingGuiPlugin.ToolbarElement.UnitTest.Create.Tooltip=Create a new unit test 
for this pipeline
 TestingGuiPlugin.ToolbarElement.UnitTest.Delete.Tooltip=Delete the active unit 
test
 TestingGuiPlugin.ToolbarElement.UnitTest.Detach.Tooltip=Detach the unit test 
from this pipeline
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/ComboFilterPopup.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/ComboFilterPopup.java
new file mode 100644
index 0000000000..af5516f427
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/ComboFilterPopup.java
@@ -0,0 +1,438 @@
+/*
+ * 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.widget;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.search.SearchMatcher;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.util.EnvironmentUtils;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.SWTException;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.widgets.Combo;
+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;
+
+/**
+ * Click-to-select-all and type-to-filter support for a toolbar {@link Combo}. 
Typing opens a popup
+ * list under the combo with the items that match the typed text.
+ */
+public class ComboFilterPopup {
+
+  public static final String DATA_KEY = ComboFilterPopup.class.getName();
+
+  private static final int MAX_VISIBLE_ITEMS = 10;
+
+  private final Combo combo;
+  private final Supplier<List<String>> itemsSupplier;
+  private final Consumer<String> onSelect;
+
+  private String originalText = "";
+  private boolean updating;
+  private boolean suppressSelectAll;
+  private boolean applied;
+  private boolean openingPopup;
+  private Shell popup;
+  private org.eclipse.swt.widgets.List list;
+  private Listener mouseDownFilter;
+
+  private ComboFilterPopup(
+      Combo combo, Supplier<List<String>> itemsSupplier, Consumer<String> 
onSelect) {
+    this.combo = combo;
+    this.itemsSupplier = itemsSupplier;
+    this.onSelect = onSelect;
+  }
+
+  /**
+   * Attach type-to-filter behaviour to {@code combo}. Safe to call more than 
once; the same
+   * instance is reused.
+   */
+  public static ComboFilterPopup attach(
+      Combo combo, Supplier<List<String>> itemsSupplier, Consumer<String> 
onSelect) {
+    ComboFilterPopup existing = get(combo);
+    if (existing != null) {
+      return existing;
+    }
+    ComboFilterPopup support = new ComboFilterPopup(combo, itemsSupplier, 
onSelect);
+    support.install();
+    combo.setData(DATA_KEY, support);
+    return support;
+  }
+
+  public static ComboFilterPopup get(Combo combo) {
+    if (combo == null || combo.isDisposed()) {
+      return null;
+    }
+    Object data = combo.getData(DATA_KEY);
+    return data instanceof ComboFilterPopup filter ? filter : null;
+  }
+
+  public boolean isPopupOpen() {
+    return popup != null && !popup.isDisposed() && popup.isVisible();
+  }
+
+  /**
+   * Rank and filter {@code items} with {@link SearchMatcher}. An empty query 
returns the items in
+   * their original order.
+   */
+  public static List<String> filterItems(Collection<String> items, String 
query) {
+    if (items == null || items.isEmpty()) {
+      return List.of();
+    }
+    List<String> source = new ArrayList<>();
+    for (String item : items) {
+      if (StringUtils.isNotEmpty(item)) {
+        source.add(item);
+      }
+    }
+    if (source.isEmpty()) {
+      return List.of();
+    }
+    if (StringUtils.isEmpty(query)) {
+      return source;
+    }
+    SearchMatcher matcher = new SearchMatcher(query, false, false, true);
+    record Scored(String name, double score) {}
+    List<Scored> scored = new ArrayList<>();
+    for (String name : source) {
+      double score = matcher.score(name);
+      if (score > 0.0) {
+        scored.add(new Scored(name, score));
+      }
+    }
+    scored.sort(
+        Comparator.comparingDouble(Scored::score)
+            .reversed()
+            .thenComparing(Scored::name, String.CASE_INSENSITIVE_ORDER));
+    List<String> matches = new ArrayList<>(scored.size());
+    for (Scored item : scored) {
+      matches.add(item.name());
+    }
+    return matches;
+  }
+
+  private void install() {
+    combo.addListener(SWT.FocusIn, event -> onFocusIn());
+    combo.addListener(
+        SWT.MouseUp,
+        event -> {
+          suppressSelectAll = false;
+          selectAllLater();
+        });
+    combo.addListener(SWT.Modify, event -> onModify());
+    combo.addListener(SWT.KeyDown, this::onKeyDown);
+    combo.addListener(SWT.Traverse, this::onTraverse);
+    combo.addListener(SWT.FocusOut, event -> 
combo.getDisplay().asyncExec(this::onFocusLost));
+    combo.addListener(SWT.Dispose, event -> closePopup());
+  }
+
+  private void onFocusIn() {
+    // Reclaiming focus while the popup is open (Hop Web) must not reset the 
typed query.
+    if (isPopupOpen()) {
+      return;
+    }
+    originalText = Const.NVL(combo.getText(), "");
+    suppressSelectAll = false;
+    applied = false;
+    selectAllLater();
+  }
+
+  private void selectAllLater() {
+    Display display = combo.getDisplay();
+    display.asyncExec(
+        () -> {
+          if (suppressSelectAll || combo.isDisposed() || 
!combo.isFocusControl()) {
+            return;
+          }
+          String text = Const.NVL(combo.getText(), "");
+          combo.setSelection(new Point(0, text.length()));
+        });
+  }
+
+  private void onModify() {
+    suppressSelectAll = true;
+    if (updating || combo.isDisposed() || !combo.isFocusControl()) {
+      return;
+    }
+    hideNativeList();
+    updatePopup(typedQuery());
+  }
+
+  /**
+   * The text the user actually typed. Native combo auto-complete selects the 
remainder of the first
+   * match; that suffix is ignored for searching.
+   */
+  private String typedQuery() {
+    String text = Const.NVL(combo.getText(), "");
+    Point selection = combo.getSelection();
+    if (selection != null && selection.y > selection.x && selection.y >= 
text.length()) {
+      return text.substring(0, Math.min(selection.x, text.length()));
+    }
+    return text;
+  }
+
+  private void onKeyDown(Event event) {
+    if (!isPopupOpen()) {
+      return;
+    }
+    switch (event.keyCode) {
+      case SWT.ARROW_DOWN -> {
+        moveSelection(1);
+        event.doit = false;
+      }
+      case SWT.ARROW_UP -> {
+        moveSelection(-1);
+        event.doit = false;
+      }
+      case SWT.CR, SWT.KEYPAD_CR -> {
+        applySelection();
+        event.doit = false;
+      }
+      case SWT.ESC -> {
+        cancel();
+        event.doit = false;
+      }
+      default -> {
+        // other keys edit the combo as usual
+      }
+    }
+  }
+
+  private void onTraverse(Event event) {
+    if (!isPopupOpen()) {
+      return;
+    }
+    if (event.detail == SWT.TRAVERSE_RETURN || event.detail == 
SWT.TRAVERSE_ESCAPE) {
+      event.doit = false;
+      event.detail = SWT.TRAVERSE_NONE;
+    }
+  }
+
+  private void onFocusLost() {
+    if (combo.isDisposed()) {
+      closePopup();
+      return;
+    }
+    if (openingPopup) {
+      return;
+    }
+    if (applied) {
+      closePopup();
+      return;
+    }
+    Control focus = combo.getDisplay().getFocusControl();
+    if (focus == combo || isPopupControl(focus)) {
+      return;
+    }
+    restoreOrApplyExactMatch();
+    closePopup();
+  }
+
+  private boolean isPopupControl(Control control) {
+    return popup != null
+        && !popup.isDisposed()
+        && control != null
+        && (control == popup || control == list);
+  }
+
+  private void updatePopup(String query) {
+    List<String> matches = filterItems(itemsSupplier.get(), query);
+    if (matches.isEmpty()) {
+      closePopup();
+      return;
+    }
+    if (!isPopupOpen()) {
+      openPopup();
+    }
+    if (list == null || list.isDisposed()) {
+      return;
+    }
+    String previous = list.getSelectionCount() > 0 ? list.getSelection()[0] : 
null;
+    list.setItems(matches.toArray(String[]::new));
+    int index = previous == null ? 0 : matches.indexOf(previous);
+    list.setSelection(index < 0 ? 0 : index);
+    layoutPopup();
+  }
+
+  private void openPopup() {
+    closePopup();
+    Shell parent = combo.getShell();
+    int style =
+        EnvironmentUtils.getInstance().isWeb() ? SWT.NONE : SWT.ON_TOP | 
SWT.NO_FOCUS | SWT.TOOL;
+    popup = new Shell(parent, style);
+    popup.setLayout(new FillLayout());
+    list = new org.eclipse.swt.widgets.List(popup, SWT.SINGLE | SWT.V_SCROLL | 
SWT.BORDER);
+    PropsUi.setLook(list);
+    list.addListener(SWT.MouseUp, event -> applySelection());
+    list.addListener(SWT.DefaultSelection, event -> applySelection());
+    layoutPopup();
+    openingPopup = true;
+    popup.setVisible(true);
+    installMouseDownFilter();
+    if (!combo.isFocusControl()) {
+      combo.setFocus();
+    }
+    combo.getDisplay().asyncExec(() -> openingPopup = false);
+  }
+
+  private void layoutPopup() {
+    if (popup == null || popup.isDisposed() || list == null || 
list.isDisposed()) {
+      return;
+    }
+    Point comboSize = combo.getSize();
+    Point location = GuiResource.calculateControlPosition(combo);
+    int itemCount = Math.max(list.getItemCount(), 1);
+    int visible = Math.min(itemCount, MAX_VISIBLE_ITEMS);
+    int itemHeight = Math.max(list.getItemHeight(), 16);
+    int height = itemHeight * visible + 8;
+    int width = Math.max(comboSize.x, 200);
+
+    Rectangle displayArea = combo.getMonitor().getClientArea();
+    int below = location.y + comboSize.y;
+    if (below + height > displayArea.y + displayArea.height
+        && location.y - height >= displayArea.y) {
+      location.y = location.y - height;
+    } else {
+      location.y = below;
+    }
+    popup.setBounds(location.x, location.y, width, height);
+  }
+
+  private void moveSelection(int delta) {
+    if (list == null || list.isDisposed() || list.getItemCount() == 0) {
+      return;
+    }
+    int index = list.getSelectionIndex();
+    if (index < 0) {
+      index = 0;
+    } else {
+      index = Math.max(0, Math.min(list.getItemCount() - 1, index + delta));
+    }
+    list.setSelection(index);
+    list.showSelection();
+  }
+
+  private void applySelection() {
+    if (list == null || list.isDisposed() || list.getSelectionCount() == 0) {
+      closePopup();
+      return;
+    }
+    String name = list.getSelection()[0];
+    applied = true;
+    updating = true;
+    try {
+      combo.setText(name);
+    } finally {
+      updating = false;
+    }
+    closePopup();
+    if (onSelect != null) {
+      onSelect.accept(name);
+    }
+  }
+
+  private void cancel() {
+    applied = true;
+    updating = true;
+    try {
+      combo.setText(Const.NVL(originalText, ""));
+    } finally {
+      updating = false;
+    }
+    closePopup();
+  }
+
+  private void restoreOrApplyExactMatch() {
+    String text = Const.NVL(combo.getText(), "");
+    List<String> items = itemsSupplier.get();
+    if (items != null && items.contains(text)) {
+      if (!text.equals(originalText) && onSelect != null) {
+        onSelect.accept(text);
+      }
+      return;
+    }
+    updating = true;
+    try {
+      combo.setText(Const.NVL(originalText, ""));
+    } finally {
+      updating = false;
+    }
+  }
+
+  private void hideNativeList() {
+    try {
+      if (combo.getListVisible()) {
+        combo.setListVisible(false);
+      }
+    } catch (SWTException ignored) {
+      // Combo list visibility is not supported on every platform (e.g. some 
RAP builds).
+    }
+  }
+
+  private void installMouseDownFilter() {
+    removeMouseDownFilter();
+    Display display = combo.getDisplay();
+    mouseDownFilter =
+        event -> {
+          if (!isPopupOpen()) {
+            return;
+          }
+          if (event.widget == combo || 
isPopupControl(asControl(event.widget))) {
+            return;
+          }
+          display.asyncExec(this::onFocusLost);
+        };
+    display.addFilter(SWT.MouseDown, mouseDownFilter);
+  }
+
+  private void removeMouseDownFilter() {
+    if (mouseDownFilter == null || combo.isDisposed()) {
+      mouseDownFilter = null;
+      return;
+    }
+    combo.getDisplay().removeFilter(SWT.MouseDown, mouseDownFilter);
+    mouseDownFilter = null;
+  }
+
+  private static Control asControl(Object widget) {
+    return widget instanceof Control control ? control : null;
+  }
+
+  private void closePopup() {
+    openingPopup = false;
+    removeMouseDownFilter();
+    if (popup != null && !popup.isDisposed()) {
+      popup.dispose();
+    }
+    popup = null;
+    list = null;
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/core/widget/ComboFilterPopupTest.java 
b/ui/src/test/java/org/apache/hop/ui/core/widget/ComboFilterPopupTest.java
new file mode 100644
index 0000000000..dc005cb82e
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/core/widget/ComboFilterPopupTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.widget;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class ComboFilterPopupTest {
+
+  private static final List<String> TESTS =
+      List.of(
+          "merge-orders",
+          "merge-customers",
+          "load-customers",
+          "validate-golden-customers",
+          "orders-daily");
+
+  @Test
+  void emptyQueryKeepsOriginalOrder() {
+    assertEquals(TESTS, ComboFilterPopup.filterItems(TESTS, ""));
+    assertEquals(TESTS, ComboFilterPopup.filterItems(TESTS, null));
+  }
+
+  @Test
+  void emptySourceIsEmpty() {
+    assertTrue(ComboFilterPopup.filterItems(List.of(), "cust").isEmpty());
+    assertTrue(ComboFilterPopup.filterItems(null, "cust").isEmpty());
+  }
+
+  @Test
+  void substringIsCaseInsensitive() {
+    List<String> matches = ComboFilterPopup.filterItems(TESTS, "CUSTOMER");
+    assertEquals(
+        List.of("load-customers", "merge-customers", 
"validate-golden-customers"), matches);
+  }
+
+  @Test
+  void exactMatchRanksAboveSubstring() {
+    List<String> names = List.of("customer-address", "customer", 
"the-customer-hub");
+    List<String> matches = ComboFilterPopup.filterItems(names, "customer");
+    assertEquals("customer", matches.get(0));
+    assertTrue(matches.contains("customer-address"));
+    assertTrue(matches.contains("the-customer-hub"));
+  }
+
+  @Test
+  void noMatchIsEmpty() {
+    assertTrue(ComboFilterPopup.filterItems(TESTS, 
"does-not-exist").isEmpty());
+  }
+
+  @Test
+  void multiTermRequiresEveryTerm() {
+    List<String> matches = ComboFilterPopup.filterItems(TESTS, "merge 
customer");
+    assertEquals(List.of("merge-customers"), matches);
+    assertTrue(ComboFilterPopup.filterItems(TESTS, "merge orders 
extra").isEmpty());
+  }
+
+  @Test
+  void blankItemsAreIgnored() {
+    List<String> matches = ComboFilterPopup.filterItems(List.of("", "keep-me", 
"  "), "keep");
+    assertEquals(List.of("keep-me"), matches);
+  }
+}

Reply via email to