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")));
+ }
}