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 2874ac8ca4 add helper showing the match (#8884)
2874ac8ca4 is described below

commit 2874ac8ca4eaf9cd4a366f07dc5df79da0e12989
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Tue Oct 13 13:35:41 2026 +0200

    add helper showing the match (#8884)
---
 .../hop/core/gui/plugin/action/GuiAction.java      | 72 ++++++++++++++++++++--
 .../hop/core/gui/plugin/action/GuiActionTest.java  | 37 +++++++++++
 .../apache/hop/ui/core/dialog/ContextDialog.java   | 53 +++++++++++++---
 .../core/dialog/messages/messages_en_US.properties |  4 ++
 .../hop/ui/core/dialog/ContextDialogTest.java      | 43 +++++++++++++
 5 files changed, 194 insertions(+), 15 deletions(-)

diff --git 
a/core/src/main/java/org/apache/hop/core/gui/plugin/action/GuiAction.java 
b/core/src/main/java/org/apache/hop/core/gui/plugin/action/GuiAction.java
index 57fae05ba9..3fd7ed8bb4 100644
--- a/core/src/main/java/org/apache/hop/core/gui/plugin/action/GuiAction.java
+++ b/core/src/main/java/org/apache/hop/core/gui/plugin/action/GuiAction.java
@@ -134,20 +134,82 @@ public class GuiAction {
    * @return the best relevance score in {@code [0,1]}
    */
   public double matchScore(SearchMatcher matcher) {
+    Match match = match(matcher);
+    return match == null ? 0.0 : match.score();
+  }
+
+  /** The field of an action that a search matched on. */
+  public enum MatchSource {
+    NAME,
+    DESCRIPTION,
+    TYPE,
+    KEYWORD,
+    CATEGORY
+  }
+
+  /**
+   * Why an action matched a search.
+   *
+   * @param score the weighted relevance score in {@code (0,1]}
+   * @param source the field that gave the best score
+   * @param matchedText the value of that field, e.g. the keyword that matched
+   */
+  public record Match(double score, MatchSource source, String matchedText) {}
+
+  /**
+   * Score this action against a prepared matcher and tell which field gave 
the best score, so the
+   * user can see why an action is in the list. When several fields score the 
same, the first one
+   * (name, description, type, keyword, category) is reported.
+   *
+   * @param matcher the shared search matcher built from the user's filter text
+   * @return the best match, or null if nothing matched
+   */
+  public Match match(SearchMatcher matcher) {
     // Weight the fields so a hit on the name ranks above a hit on a 
tooltip/type, which in turn
     // ranks above a hit on a keyword or category. This keeps the most 
relevant actions (those whose
     // name matches) at the top and stops loosely-related keyword/category 
hits from crowding in.
-    double best = matcher.score(name);
-    best = Math.max(best, 0.9 * matcher.score(tooltip));
+    Match best = better(null, matcher.score(name), MatchSource.NAME, name);
+    best = better(best, 0.9 * matcher.score(tooltip), MatchSource.DESCRIPTION, 
tooltip);
     if (type != null) {
-      best = Math.max(best, 0.9 * matcher.score(type.name()));
+      best = better(best, 0.9 * matcher.score(type.name()), MatchSource.TYPE, 
type.name());
     }
     if (keywords != null) {
       for (String keyword : keywords) {
-        best = Math.max(best, 0.8 * matcher.score(keyword));
+        double score = 0.8 * matcher.score(keyword);
+        if (score > 0.0) {
+          best = better(best, score, MatchSource.KEYWORD, 
matchingPart(matcher, keyword));
+        }
       }
     }
-    return Math.max(best, 0.7 * matcher.score(category));
+    return better(best, 0.7 * matcher.score(category), MatchSource.CATEGORY, 
category);
+  }
+
+  /**
+   * Keywords are often a single comma separated string 
("sql,query,database,select,jdbc"). Report
+   * the part that matched best rather than the whole list, or the whole 
string when the match spans
+   * several parts.
+   */
+  private static String matchingPart(SearchMatcher matcher, String keyword) {
+    if (keyword == null || !keyword.contains(",")) {
+      return keyword;
+    }
+    String best = keyword;
+    double bestScore = 0.0;
+    for (String part : keyword.split(",")) {
+      double score = matcher.score(part);
+      if (score > bestScore) {
+        bestScore = score;
+        best = part.trim();
+      }
+    }
+    return best;
+  }
+
+  private static Match better(Match best, double score, MatchSource source, 
String text) {
+    if (score > 0.0 && (best == null || score > best.score())) {
+      return new Match(score, source, text);
+    }
+    return best;
   }
 
   @Override
diff --git 
a/core/src/test/java/org/apache/hop/core/gui/plugin/action/GuiActionTest.java 
b/core/src/test/java/org/apache/hop/core/gui/plugin/action/GuiActionTest.java
index 8465f2baf3..3a92bd14af 100644
--- 
a/core/src/test/java/org/apache/hop/core/gui/plugin/action/GuiActionTest.java
+++ 
b/core/src/test/java/org/apache/hop/core/gui/plugin/action/GuiActionTest.java
@@ -18,6 +18,7 @@
 package org.apache.hop.core.gui.plugin.action;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import org.apache.hop.core.search.SearchMatcher;
@@ -93,4 +94,40 @@ class GuiActionTest {
     copy.getTooltipHints().clear();
     assertEquals(1, original.getTooltipHints().size());
   }
+
+  @Test
+  void matchReportsTheFieldThatPutTheActionInTheList() {
+    SearchMatcher select = new SearchMatcher("select", false, false, true);
+
+    GuiAction.Match onName = selectValues().match(select);
+    assertEquals(GuiAction.MatchSource.NAME, onName.source());
+    assertEquals("Select values", onName.matchedText());
+
+    GuiAction tableInput =
+        new GuiAction(
+            "create-transform-TableInput",
+            GuiActionType.Create,
+            "Table input",
+            "Read information from a database table.",
+            null,
+            (shift, control, t) -> {});
+    tableInput.getKeywords().add("sql,query,database,select,jdbc");
+    tableInput.setCategory("Input");
+    GuiAction.Match onKeyword = tableInput.match(select);
+    assertEquals(GuiAction.MatchSource.KEYWORD, onKeyword.source());
+    // Only the comma separated part that matched, not the whole keyword list
+    assertEquals("select", onKeyword.matchedText());
+    assertEquals(tableInput.matchScore(select), onKeyword.score());
+
+    GuiAction.Match onCategory = tableInput.match(new SearchMatcher("input", 
false, false, true));
+    assertEquals(GuiAction.MatchSource.NAME, onCategory.source());
+
+    assertNull(dummy().match(select));
+  }
+
+  @Test
+  void matchOnDescription() {
+    GuiAction.Match match = selectValues().match(new SearchMatcher("remove", 
false, false, true));
+    assertEquals(GuiAction.MatchSource.DESCRIPTION, match.source());
+  }
 }
diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
index 8cd503a344..a17761ce56 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
@@ -117,6 +117,10 @@ public class ContextDialog extends Dialog {
   private Shell shell;
   private Text wSearch;
   private Label wlTooltip;
+
+  /** Why each filtered item matched the current search; empty when there is 
no search text. */
+  private final Map<Item, GuiAction.Match> matches = new IdentityHashMap<>();
+
   private Canvas wCanvas;
   private ScrolledComposite wScrolledComposite;
 
@@ -436,7 +440,8 @@ public class ContextDialog extends Dialog {
     FormData fdlTooltip = new FormData();
     fdlTooltip.left = new FormAttachment(0, 0);
     fdlTooltip.right = new FormAttachment(100, 0);
-    fdlTooltip.top = new FormAttachment(100, -(int) (props.getZoomFactor() * 
50));
+    // Room for the "matched on" line, the description and the usage hints 
below it.
+    fdlTooltip.top = new FormAttachment(100, -(int) (props.getZoomFactor() * 
66));
     fdlTooltip.bottom = new FormAttachment(100, 0);
     wTooltipComposite.setLayoutData(fdlTooltip);
 
@@ -1526,7 +1531,8 @@ public class ContextDialog extends Dialog {
     } else {
 
       this.selectedItem = selectedItem;
-      wlTooltip.setText(selectedItem.getAction().getDisplayTooltip());
+      wlTooltip.setText(
+          describeMatch(matches.get(selectedItem)) + 
selectedItem.getAction().getDisplayTooltip());
       selectedItem.setSelected(true);
 
       // See if we need to show the selected item.
@@ -1614,6 +1620,26 @@ public class ContextDialog extends Dialog {
     return candidate.startsWith(wanted) ? 0.9 : 0.8;
   }
 
+  /**
+   * The first line of the information area: which field put the item in the 
list, e.g. "Matched on
+   * keyword: select". Nothing when there is no search or the name matched, 
since that is obvious.
+   */
+  static String describeMatch(GuiAction.Match match) {
+    if (match == null || match.source() == GuiAction.MatchSource.NAME) {
+      return "";
+    }
+    String text = StringUtils.normalizeSpace(Const.NVL(match.matchedText(), 
""));
+    String line =
+        switch (match.source()) {
+          case DESCRIPTION -> BaseMessages.getString(PKG, 
"ContextDialog.MatchedOn.Description");
+          case TYPE -> BaseMessages.getString(PKG, 
"ContextDialog.MatchedOn.Type", text);
+          case KEYWORD -> BaseMessages.getString(PKG, 
"ContextDialog.MatchedOn.Keyword", text);
+          case CATEGORY -> BaseMessages.getString(PKG, 
"ContextDialog.MatchedOn.Category", text);
+          default -> "";
+        };
+    return line + "\n";
+  }
+
   public void filter(String text) {
 
     if (text == null) {
@@ -1621,6 +1647,7 @@ public class ContextDialog extends Dialog {
     }
 
     filteredItems.clear();
+    matches.clear();
     if (StringUtils.isEmpty(text)) {
       filteredItems.addAll(items);
     } else {
@@ -1628,18 +1655,24 @@ public class ContextDialog extends Dialog {
       // "Exact match" is on, keep the matches and sort them best-first.
       boolean nameOnly = isExactMatch();
       SearchMatcher matcher = new SearchMatcher(text, false, false, true);
-      Map<Item, Double> scores = new IdentityHashMap<>();
       for (Item item : items) {
-        double score =
-            nameOnly
-                ? exactMatchScore(item.getAction().getName(), text)
-                : item.getAction().matchScore(matcher);
-        if (score > 0.0) {
-          scores.put(item, score);
+        GuiAction.Match match;
+        if (nameOnly) {
+          double score = exactMatchScore(item.getAction().getName(), text);
+          match =
+              score > 0.0
+                  ? new GuiAction.Match(
+                      score, GuiAction.MatchSource.NAME, 
item.getAction().getName())
+                  : null;
+        } else {
+          match = item.getAction().match(matcher);
+        }
+        if (match != null) {
+          matches.put(item, match);
           filteredItems.add(item);
         }
       }
-      filteredItems.sort((a, b) -> Double.compare(scores.get(b), 
scores.get(a)));
+      filteredItems.sort((a, b) -> Double.compare(matches.get(b).score(), 
matches.get(a).score()));
     }
 
     // An item called exactly what was typed comes first, whatever it scored.
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 3ca64e059d..d8b72da7e3 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
@@ -52,6 +52,10 @@ ContextDialog.GuiAction.ExpandCategories.Tooltip=Expand all 
categories
 ContextDialog.GuiAction.FixedWidth.Label=Fixed width
 ContextDialog.GuiAction.FixedWidth.Tooltip=Show icons using a fixed width
 ContextDialog.GuiAction.ExactMatch.Label=Exact match
+ContextDialog.MatchedOn.Description=Matched on: description
+ContextDialog.MatchedOn.Type=Matched on: action type {0}
+ContextDialog.MatchedOn.Keyword=Matched on: keyword {0}
+ContextDialog.MatchedOn.Category=Matched on: category {0}
 ContextDialog.GuiAction.ExactMatch.Tooltip=Only show items whose name contains 
the search text. Descriptions, keywords and categories are not searched and 
typos are not forgiven.
 ContextDialog.GuiAction.ShowCategories.Label=Show categories
 ContextDialog.GuiAction.ShowCategories.Tooltip=Show categories
diff --git 
a/ui/src/test/java/org/apache/hop/ui/core/dialog/ContextDialogTest.java 
b/ui/src/test/java/org/apache/hop/ui/core/dialog/ContextDialogTest.java
index 3e5bb62d06..8041e61395 100644
--- a/ui/src/test/java/org/apache/hop/ui/core/dialog/ContextDialogTest.java
+++ b/ui/src/test/java/org/apache/hop/ui/core/dialog/ContextDialogTest.java
@@ -17,9 +17,11 @@
 
 package org.apache.hop.ui.core.dialog;
 
+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 org.apache.hop.core.gui.plugin.action.GuiAction;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 
@@ -65,4 +67,45 @@ class ContextDialogTest {
   void nullNameMatchesNothing() {
     assertFalse(ContextDialog.isExactName(null, "Table input"));
   }
+
+  @Test
+  @DisplayName("exact match: only names containing the text match, nothing 
fuzzy")
+  void exactMatchScoreIsLiteralOnTheName() {
+    assertTrue(ContextDialog.exactMatchScore("Select values", "select") > 0.0);
+    assertTrue(ContextDialog.exactMatchScore("Table input", "INPUT") > 0.0);
+    assertEquals(0.0, ContextDialog.exactMatchScore("Table input", "select"));
+    // A typo is not forgiven
+    assertEquals(0.0, ContextDialog.exactMatchScore("Select values", "selct"));
+    assertEquals(0.0, ContextDialog.exactMatchScore("Select values", ""));
+    assertEquals(0.0, ContextDialog.exactMatchScore("Select values", null));
+  }
+
+  @Test
+  @DisplayName("exact match ranks equal names over prefixes over substrings")
+  void exactMatchScoreRanking() {
+    double equal = ContextDialog.exactMatchScore("Table input", "table input");
+    double prefix = ContextDialog.exactMatchScore("Table input", "table");
+    double substring = ContextDialog.exactMatchScore("Spark lake table input", 
"table");
+    assertTrue(equal > prefix);
+    assertTrue(prefix > substring);
+    assertTrue(substring > 0.0);
+  }
+
+  @Test
+  @DisplayName("the information area says why an item matched, except for an 
obvious name match")
+  void describeMatch() {
+    assertEquals("", ContextDialog.describeMatch(null));
+    assertEquals(
+        "",
+        ContextDialog.describeMatch(
+            new GuiAction.Match(0.97, GuiAction.MatchSource.NAME, "Select 
values")));
+    assertEquals(
+        "Matched on: keyword select\n",
+        ContextDialog.describeMatch(
+            new GuiAction.Match(0.78, GuiAction.MatchSource.KEYWORD, 
"select")));
+    assertEquals(
+        "Matched on: description\n",
+        ContextDialog.describeMatch(
+            new GuiAction.Match(0.8, GuiAction.MatchSource.DESCRIPTION, 
"Some\nlong text")));
+  }
 }

Reply via email to