This is an automated email from the ASF dual-hosted git repository.
mattcasters 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 ac83653831 Issue #8621 : Find a value in a table view (#8622)
ac83653831 is described below
commit ac83653831ec495662eb863e830c117be7121299
Author: Matt Casters <[email protected]>
AuthorDate: Mon Sep 28 10:43:55 2026 +0200
Issue #8621 : Find a value in a table view (#8622)
* Issue #8621 : Find a value in a table view
The table search button opens a menu with Find value and Find column.
Find value searches the checked columns and shows the matching cell.
Ctrl-F opens that dialog.
* Issue #8621 : Show the find menu at the click
The toolbar button's stored control is the whole toolbar, so the menu
opened at the left edge. Place it at the pointer instead.
* Issue #8621 : Do not click a found cell or resume a stale search
A hit no longer opens a column click dialog or the multi-line editor.
Changing the search text, case, or regular expression starts the next
search at the current cell instead of after the previous hit.
---
.../ui/core/widget/TableViewFindRevealTest.java | 264 +++++++++++++++
.../hop/ui/core/dialog/TableViewFindDialog.java | 353 +++++++++++++++++++++
.../org/apache/hop/ui/core/widget/TableView.java | 176 +++++++++-
.../apache/hop/ui/core/widget/TableViewFind.java | 161 ++++++++++
.../core/dialog/messages/messages_en_US.properties | 14 +
.../core/widget/messages/messages_de_DE.properties | 2 +-
.../core/widget/messages/messages_en_US.properties | 8 +-
.../core/widget/messages/messages_es_AR.properties | 2 +-
.../core/widget/messages/messages_es_ES.properties | 2 +-
.../core/widget/messages/messages_fr_FR.properties | 2 +-
.../core/widget/messages/messages_it_IT.properties | 2 +-
.../core/widget/messages/messages_ja_JP.properties | 2 +-
.../core/widget/messages/messages_ko_KR.properties | 2 +-
.../core/widget/messages/messages_pt_BR.properties | 2 +-
.../core/widget/messages/messages_zh_CN.properties | 2 +-
.../hop/ui/core/widget/TableViewFindTest.java | 178 +++++++++++
16 files changed, 1152 insertions(+), 20 deletions(-)
diff --git
a/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewFindRevealTest.java
b/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewFindRevealTest.java
new file mode 100644
index 0000000000..8beb5aa908
--- /dev/null
+++
b/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewFindRevealTest.java
@@ -0,0 +1,264 @@
+/*
+ * 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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Text;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * {@link TableView#revealFoundCell(int, int)} selects editable values and
only shows read-only
+ * ones.
+ */
+@Tag("uitest")
+class TableViewFindRevealTest extends SwtBotTestBase {
+
+ @Test
+ void editableGridSelectsTheCellValue() {
+ AtomicReference<TableView> viewRef = new AtomicReference<>();
+ withScene(
+ shell -> {
+ shell.setLayout(new FillLayout());
+ TableView view = newTableView(shell, false);
+ view.table.getItem(0).setText(2, "one");
+ view.table.getItem(1).setText(2, "needle");
+ viewRef.set(view);
+ },
+ bot -> {
+ Revealed revealed =
+ onUi(
+ () -> {
+ TableView view = viewRef.get();
+ view.revealFoundCell(1, 1);
+ Text editor = findText(view.table);
+ return new Revealed(
+ view.table.getSelectionIndex(),
+ view.getActiveTableColumn(),
+ editor == null ? null : editor.getText(),
+ editor == null ? null : editor.getSelection());
+ });
+ assertEquals(1, revealed.row());
+ assertEquals(2, revealed.column());
+ assertEquals("needle", revealed.text());
+ assertNotNull(revealed.selection());
+ assertEquals(new Point(0, "needle".length()), revealed.selection());
+ });
+ }
+
+ @Test
+ void selectionAdapterColumnIsShownWithoutClickingIt() {
+ AtomicReference<TableView> viewRef = new AtomicReference<>();
+ AtomicInteger clicks = new AtomicInteger();
+ withScene(
+ shell -> {
+ shell.setLayout(new FillLayout());
+ ColumnInfo[] columns = {
+ new ColumnInfo("Name", ColumnInfo.COLUMN_TYPE_TEXT, false, false),
+ new ColumnInfo("Value", ColumnInfo.COLUMN_TYPE_TEXT, false, false),
+ };
+ columns[1].setSelectionAdapter(
+ new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ clicks.incrementAndGet();
+ }
+ });
+ TableView view =
+ new TableView(
+ new Variables(),
+ shell,
+ SWT.BORDER | SWT.FULL_SELECTION,
+ columns,
+ 1,
+ null,
+ PropsUi.getInstance());
+ view.table.getItem(0).setText(2, "needle");
+ viewRef.set(view);
+ },
+ bot -> {
+ Revealed revealed =
+ onUi(
+ () -> {
+ TableView view = viewRef.get();
+ view.revealFoundCell(0, 1);
+ return new Revealed(
+ view.table.getSelectionIndex(),
+ view.getActiveTableColumn(),
+ findText(view.table) == null ? null : "open",
+ null);
+ });
+ assertEquals(0, clicks.get());
+ assertEquals(0, revealed.row());
+ assertEquals(2, revealed.column());
+ assertNull(revealed.text());
+ });
+ }
+
+ @Test
+ void multilineValueIsShownWithoutThePopOutEditor() {
+ AtomicReference<TableView> viewRef = new AtomicReference<>();
+ withScene(
+ shell -> {
+ shell.setLayout(new FillLayout());
+ TableView view = newTableView(shell, false);
+ view.table.getItem(0).setText(1, "one\ntwo");
+ viewRef.set(view);
+ },
+ bot -> {
+ Revealed revealed =
+ onUi(
+ () -> {
+ TableView view = viewRef.get();
+ int shellsBefore = view.getDisplay().getShells().length;
+ view.revealFoundCell(0, 0);
+ int shellsAfter = view.getDisplay().getShells().length;
+ return new Revealed(
+ view.table.getSelectionIndex(),
+ view.getActiveTableColumn(),
+ findText(view.table) == null && shellsBefore ==
shellsAfter ? null : "open",
+ null);
+ });
+ assertEquals(0, revealed.row());
+ assertEquals(1, revealed.column());
+ assertNull(revealed.text());
+ });
+ }
+
+ @Test
+ void readOnlyColumnShowsTheRowWithoutAnEditor() {
+ AtomicReference<TableView> viewRef = new AtomicReference<>();
+ withScene(
+ shell -> {
+ shell.setLayout(new FillLayout());
+ viewRef.set(newTableView(shell, true));
+ },
+ bot -> {
+ Revealed revealed =
+ onUi(
+ () -> {
+ TableView view = viewRef.get();
+ view.table.getItem(0).setText(1, "alpha");
+ view.revealFoundCell(0, 0);
+ Text editor = findText(view.table);
+ return new Revealed(
+ view.table.getSelectionIndex(),
+ view.getActiveTableColumn(),
+ editor == null ? null : editor.getText(),
+ null);
+ });
+ assertEquals(0, revealed.row());
+ assertEquals(1, revealed.column());
+ assertNull(revealed.text());
+ });
+ }
+
+ @Test
+ void readOnlyTableShowsTheRowWithoutAnEditor() {
+ AtomicReference<TableView> viewRef = new AtomicReference<>();
+ withScene(
+ shell -> {
+ shell.setLayout(new FillLayout());
+ TableView view = newTableView(shell, false);
+ view.setReadonly(true);
+ viewRef.set(view);
+ },
+ bot -> {
+ Revealed revealed =
+ onUi(
+ () -> {
+ TableView view = viewRef.get();
+ view.table.getItem(0).setText(1, "alpha");
+ view.revealFoundCell(0, 0);
+ return new Revealed(
+ view.table.getSelectionIndex(),
+ view.getActiveTableColumn(),
+ findText(view.table) == null ? null : "open",
+ null);
+ });
+ assertEquals(0, revealed.row());
+ assertEquals(1, revealed.column());
+ assertNull(revealed.text());
+ });
+ }
+
+ private record Revealed(int row, int column, String text, Point selection) {}
+
+ private static TableView newTableView(Composite parent, boolean
columnReadOnly) {
+ ColumnInfo[] columns = {
+ new ColumnInfo("Name", ColumnInfo.COLUMN_TYPE_TEXT, false,
columnReadOnly),
+ new ColumnInfo("Value", ColumnInfo.COLUMN_TYPE_TEXT, false,
columnReadOnly),
+ };
+ return new TableView(
+ new Variables(),
+ parent,
+ SWT.BORDER | SWT.FULL_SELECTION,
+ columns,
+ 2,
+ null,
+ PropsUi.getInstance());
+ }
+
+ private static Text findText(Composite parent) {
+ for (Control child : parent.getChildren()) {
+ if (child instanceof Text text && !text.isDisposed()) {
+ return text;
+ }
+ if (child instanceof Composite composite) {
+ Text found = findText(composite);
+ if (found != null) {
+ return found;
+ }
+ }
+ }
+ return null;
+ }
+
+ private static <T> T onUi(Supplier<T> supplier) {
+ AtomicReference<T> result = new AtomicReference<>();
+ AtomicReference<RuntimeException> failure = new AtomicReference<>();
+ display.syncExec(
+ () -> {
+ try {
+ result.set(supplier.get());
+ } catch (RuntimeException e) {
+ failure.set(e);
+ }
+ });
+ if (failure.get() != null) {
+ throw failure.get();
+ }
+ return result.get();
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/dialog/TableViewFindDialog.java
b/ui/src/main/java/org/apache/hop/ui/core/dialog/TableViewFindDialog.java
new file mode 100644
index 0000000000..c71728563c
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/TableViewFindDialog.java
@@ -0,0 +1,353 @@
+/*
+ * 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.dialog;
+
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.core.widget.TableViewFind;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swt.widgets.TableColumn;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
+
+/** Find-a-value dialog for a {@link TableView}. Stays open across Find first
and Find next. */
+public class TableViewFindDialog {
+ private static final Class<?> PKG = TableViewFindDialog.class;
+
+ private static String lastFind = "";
+ private static boolean lastCaseSensitive;
+ private static boolean lastRegex;
+
+ private final Shell parent;
+ private final TableView tableView;
+ private final PropsUi props;
+
+ private Shell shell;
+ private Table wColumns;
+ private Text wFind;
+ private Button wCaseSensitive;
+ private Button wRegex;
+ private Label wlStatus;
+ private TableViewFind.Hit lastHit;
+
+ public TableViewFindDialog(Shell parent, TableView tableView) {
+ this.parent = parent;
+ this.tableView = tableView;
+ this.props = PropsUi.getInstance();
+ }
+
+ public void open() {
+ if (parent == null || parent.isDisposed() || tableView == null ||
tableView.isDisposed()) {
+ return;
+ }
+ TableViewFind.Grid grid = tableView.captureFindGrid();
+ if (grid == null || grid.columnNames() == null ||
grid.columnNames().length == 0) {
+ return;
+ }
+
+ shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.RESIZE |
SWT.APPLICATION_MODAL | SWT.SHEET);
+ PropsUi.setLook(shell);
+ shell.setImage(GuiResource.getInstance().getImageSearch());
+ shell.setText(BaseMessages.getString(PKG,
"TableViewFindDialog.Shell.Title"));
+
+ FormLayout formLayout = new FormLayout();
+ formLayout.marginWidth = PropsUi.getFormMargin();
+ formLayout.marginHeight = PropsUi.getFormMargin();
+ shell.setLayout(formLayout);
+
+ int margin = PropsUi.getMargin();
+
+ Label wlColumns = new Label(shell, SWT.LEFT);
+ PropsUi.setLook(wlColumns);
+ wlColumns.setText(BaseMessages.getString(PKG,
"TableViewFindDialog.Columns.Label"));
+ FormData fdlColumns = new FormData();
+ fdlColumns.left = new FormAttachment(0, 0);
+ fdlColumns.top = new FormAttachment(0, 0);
+ wlColumns.setLayoutData(fdlColumns);
+
+ wColumns =
+ new Table(shell, SWT.CHECK | SWT.BORDER | SWT.V_SCROLL | SWT.H_SCROLL
| SWT.FULL_SELECTION);
+ PropsUi.setLook(wColumns);
+ wColumns.setHeaderVisible(false);
+ TableColumn nameColumn = new TableColumn(wColumns, SWT.LEFT);
+ int[] visual = grid.visualDataColumns();
+ if (visual != null) {
+ for (int dataColumn : visual) {
+ if (dataColumn < 0 || dataColumn >= grid.columnNames().length) {
+ continue;
+ }
+ TableItem item = new TableItem(wColumns, SWT.NONE);
+ item.setText(grid.columnNames()[dataColumn]);
+ item.setData(dataColumn);
+ item.setChecked(true);
+ }
+ }
+ wColumns.addListener(
+ SWT.Resize,
+ e -> {
+ if (!nameColumn.isDisposed()) {
+ nameColumn.setWidth(Math.max(wColumns.getClientArea().width, 1));
+ }
+ });
+ FormData fdColumns = new FormData();
+ fdColumns.left = new FormAttachment(0, 0);
+ fdColumns.top = new FormAttachment(wlColumns, margin);
+ fdColumns.right = new FormAttachment(40, 0);
+ fdColumns.height = (int) Math.round(200 * PropsUi.getNativeZoomFactor());
+ wColumns.setLayoutData(fdColumns);
+
+ Button wAll = new Button(shell, SWT.PUSH);
+ wAll.setText(BaseMessages.getString(PKG, "TableViewFindDialog.All.Label"));
+ wAll.addListener(SWT.Selection, e -> setAllChecked(true));
+ FormData fdAll = new FormData();
+ fdAll.left = new FormAttachment(0, 0);
+ fdAll.top = new FormAttachment(wColumns, margin);
+ wAll.setLayoutData(fdAll);
+
+ Button wNone = new Button(shell, SWT.PUSH);
+ wNone.setText(BaseMessages.getString(PKG,
"TableViewFindDialog.None.Label"));
+ wNone.addListener(SWT.Selection, e -> setAllChecked(false));
+ FormData fdNone = new FormData();
+ fdNone.left = new FormAttachment(wAll, margin);
+ fdNone.top = new FormAttachment(wAll, 0, SWT.CENTER);
+ wNone.setLayoutData(fdNone);
+
+ Label wlFind = new Label(shell, SWT.LEFT);
+ PropsUi.setLook(wlFind);
+ wlFind.setText(BaseMessages.getString(PKG,
"TableViewFindDialog.Find.Label"));
+ FormData fdlFind = new FormData();
+ fdlFind.left = new FormAttachment(wColumns, margin);
+ fdlFind.top = new FormAttachment(0, 0);
+ wlFind.setLayoutData(fdlFind);
+
+ wFind = new Text(shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
+ PropsUi.setLook(wFind);
+ wFind.setData(BaseDialog.NO_DEFAULT_HANDLER, Boolean.TRUE);
+ // Consume Enter here and cancel the traverse so the default Find next
button does not run a
+ // second search for the same key.
+ wFind.addListener(
+ SWT.Traverse,
+ e -> {
+ if (e.detail == SWT.TRAVERSE_RETURN) {
+ e.doit = false;
+ find(false);
+ }
+ });
+ FormData fdFind = new FormData();
+ fdFind.left = new FormAttachment(wlFind, margin);
+ fdFind.top = new FormAttachment(wlFind, 0, SWT.CENTER);
+ fdFind.right = new FormAttachment(100, 0);
+ wFind.setLayoutData(fdFind);
+
+ wCaseSensitive = new Button(shell, SWT.CHECK);
+ PropsUi.setLook(wCaseSensitive);
+ wCaseSensitive.setText(BaseMessages.getString(PKG,
"TableViewFindDialog.CaseSensitive.Label"));
+ FormData fdCase = new FormData();
+ fdCase.left = new FormAttachment(wColumns, margin);
+ fdCase.top = new FormAttachment(wFind, margin);
+ fdCase.right = new FormAttachment(100, 0);
+ wCaseSensitive.setLayoutData(fdCase);
+
+ wRegex = new Button(shell, SWT.CHECK);
+ PropsUi.setLook(wRegex);
+ wRegex.setText(BaseMessages.getString(PKG,
"TableViewFindDialog.RegularExpression.Label"));
+ FormData fdRegex = new FormData();
+ fdRegex.left = new FormAttachment(wColumns, margin);
+ fdRegex.top = new FormAttachment(wCaseSensitive, margin);
+ fdRegex.right = new FormAttachment(100, 0);
+ wRegex.setLayoutData(fdRegex);
+
+ wlStatus = new Label(shell, SWT.LEFT);
+ PropsUi.setLook(wlStatus);
+ FormData fdStatus = new FormData();
+ fdStatus.left = new FormAttachment(0, 0);
+ fdStatus.right = new FormAttachment(100, 0);
+ fdStatus.top = new FormAttachment(wAll, margin);
+ wlStatus.setLayoutData(fdStatus);
+
+ Button wFindFirst = new Button(shell, SWT.PUSH);
+ wFindFirst.setText(BaseMessages.getString(PKG,
"TableViewFindDialog.FindFirst.Button"));
+ wFindFirst.addListener(SWT.Selection, e -> find(true));
+
+ Button wFindNext = new Button(shell, SWT.PUSH);
+ wFindNext.setText(BaseMessages.getString(PKG,
"TableViewFindDialog.FindNext.Button"));
+ wFindNext.addListener(SWT.Selection, e -> find(false));
+
+ Button wCancel = new Button(shell, SWT.PUSH);
+ wCancel.setText(BaseMessages.getString(PKG, "System.Button.Cancel"));
+ wCancel.addListener(SWT.Selection, e -> close());
+
+ BaseTransformDialog.positionBottomButtons(
+ shell, new Button[] {wFindFirst, wFindNext, wCancel}, margin,
wlStatus);
+ shell.setDefaultButton(wFindNext);
+
+ wFind.setText(lastFind == null ? "" : lastFind);
+ wCaseSensitive.setSelection(lastCaseSensitive);
+ wRegex.setSelection(lastRegex);
+ // A changed query, case flag, or regex flag must not resume after the
previous hit.
+ wFind.addListener(SWT.Modify, e -> lastHit = null);
+ wCaseSensitive.addListener(SWT.Selection, e -> lastHit = null);
+ wRegex.addListener(SWT.Selection, e -> lastHit = null);
+ wFind.selectAll();
+ wFind.setFocus();
+
+ BaseDialog.defaultShellHandling(shell, c -> find(false), this::close,
false);
+ }
+
+ private void setAllChecked(boolean checked) {
+ if (wColumns == null || wColumns.isDisposed()) {
+ return;
+ }
+ for (TableItem item : wColumns.getItems()) {
+ item.setChecked(checked);
+ }
+ }
+
+ private boolean[] included(int columnCount) {
+ boolean[] included = new boolean[columnCount];
+ if (wColumns == null || wColumns.isDisposed()) {
+ return included;
+ }
+ for (TableItem item : wColumns.getItems()) {
+ if (!item.getChecked()) {
+ continue;
+ }
+ Object data = item.getData();
+ if (data instanceof Integer index && index >= 0 && index < columnCount) {
+ included[index] = true;
+ }
+ }
+ return included;
+ }
+
+ private void find(boolean fromStart) {
+ if (shell == null || shell.isDisposed()) {
+ return;
+ }
+ if (tableView == null || tableView.isDisposed()) {
+ close();
+ return;
+ }
+ TableViewFind.Grid grid = tableView.captureFindGrid();
+ if (grid == null || grid.columnNames() == null) {
+ close();
+ return;
+ }
+
+ String query = wFind.getText();
+ boolean caseSensitive = wCaseSensitive.getSelection();
+ boolean regex = wRegex.getSelection();
+ lastFind = query;
+ lastCaseSensitive = caseSensitive;
+ lastRegex = regex;
+
+ boolean continued = !fromStart && lastHit != null;
+ int startRow;
+ int startColumn;
+ boolean inclusive;
+ if (fromStart || lastHit == null) {
+ if (fromStart) {
+ startRow = 0;
+ startColumn = -1;
+ } else {
+ startRow = grid.activeRow();
+ startColumn = grid.activeDataColumn();
+ }
+ inclusive = true;
+ } else {
+ startRow = lastHit.row();
+ startColumn = lastHit.dataColumn();
+ inclusive = false;
+ }
+
+ TableViewFind.Result result =
+ TableViewFind.find(
+ grid.cellValues(),
+ grid.visualDataColumns(),
+ included(grid.columnNames().length),
+ query,
+ caseSensitive,
+ regex,
+ startRow,
+ startColumn,
+ inclusive);
+
+ switch (result.status()) {
+ case EMPTY_QUERY ->
+ setStatus(BaseMessages.getString(PKG,
"TableViewFindDialog.Status.Empty"));
+ case INVALID_REGEX ->
+ setStatus(
+ BaseMessages.getString(
+ PKG,
+ "TableViewFindDialog.Status.InvalidRegex",
+ result.regexMessage() == null ? "" : result.regexMessage()));
+ case NOT_FOUND ->
+ setStatus(
+ BaseMessages.getString(
+ PKG,
+ continued
+ ? "TableViewFindDialog.Status.NoMore"
+ : "TableViewFindDialog.Status.NotFound"));
+ case FOUND -> showHit(grid, result.hit());
+ }
+ }
+
+ private void showHit(TableViewFind.Grid grid, TableViewFind.Hit hit) {
+ lastHit = hit;
+ tableView.revealFoundCell(hit.row(), hit.dataColumn());
+ String name = "";
+ if (hit.dataColumn() >= 0 && hit.dataColumn() < grid.columnNames().length)
{
+ name = grid.columnNames()[hit.dataColumn()];
+ }
+ setStatus(
+ BaseMessages.getString(
+ PKG,
+ "TableViewFindDialog.Status.Found",
+ Integer.toString(hit.row() + 1),
+ name == null ? "" : name));
+ if (shell != null && !shell.isDisposed()) {
+ shell.forceActive();
+ wFind.setFocus();
+ }
+ }
+
+ private void setStatus(String message) {
+ if (wlStatus != null && !wlStatus.isDisposed()) {
+ wlStatus.setText(message == null ? "" : message);
+ }
+ }
+
+ private boolean close() {
+ if (shell != null && !shell.isDisposed()) {
+ props.setScreen(new WindowProperty(shell));
+ shell.dispose();
+ }
+ return true;
+ }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
index bccae01587..8ed7e4b427 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
@@ -56,6 +56,7 @@ import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
import org.apache.hop.ui.core.dialog.ErrorDialog;
import org.apache.hop.ui.core.dialog.MessageBox;
import org.apache.hop.ui.core.dialog.TableViewColumnViewDialog;
+import org.apache.hop.ui.core.dialog.TableViewFindDialog;
import org.apache.hop.ui.core.gui.GuiResource;
import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
import org.apache.hop.ui.core.gui.IToolbarContainer;
@@ -885,6 +886,11 @@ public class TableView extends Composite {
return new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
+ if (isFindShortcut(e)) {
+ e.doit = false;
+ findValue();
+ return;
+ }
if (activeTableItem == null) {
return;
}
@@ -1175,6 +1181,11 @@ public class TableView extends Composite {
return new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
+ if (isFindShortcut(e)) {
+ e.doit = false;
+ findValue();
+ return;
+ }
boolean right = false;
boolean left = false;
@@ -1492,11 +1503,16 @@ public class TableView extends Composite {
}
if (!removeToolItems.contains(ID_TOOLBAR_NAVIGATE_TO_COLUMN)) {
- MenuItem miNavigateToColumn = new MenuItem(mRow, SWT.NONE);
- miNavigateToColumn.setText(
+ MenuItem miFindValue = new MenuItem(mRow, SWT.NONE);
+ miFindValue.setText(
+ OsHelper.customizeMenuitemText(BaseMessages.getString(PKG,
"TableView.menu.FindValue")));
+ miFindValue.addListener(SWT.Selection, e -> findValue());
+
+ MenuItem miFindColumn = new MenuItem(mRow, SWT.NONE);
+ miFindColumn.setText(
OsHelper.customizeMenuitemText(
BaseMessages.getString(PKG, "TableView.menu.NavigateToColumn")));
- miNavigateToColumn.addListener(SWT.Selection, e -> navigateToColumn());
+ miFindColumn.addListener(SWT.Selection, e -> navigateToColumn());
}
if (!removeToolItems.contains(ID_TOOLBAR_TABLE_VIEWS)) {
@@ -1607,6 +1623,11 @@ public class TableView extends Composite {
}
private void comboKeyPressed(KeyEvent e) {
+ if (isFindShortcut(e)) {
+ e.doit = false;
+ findValue();
+ return;
+ }
// "ENTER": close the text editor and copy the data over
//
@@ -4528,15 +4549,62 @@ public class TableView extends Composite {
}
}
- /**
- * Open a searchable column picker and scroll the table horizontally so the
chosen column is
- * visible. Useful for wide tables (preview grids, field mapping dialogs,
etc.).
- */
+ /** Popup under the search toolbar button: find a value, or jump to a
column. */
@GuiToolbarElement(
root = ID_TOOLBAR,
id = ID_TOOLBAR_NAVIGATE_TO_COLUMN,
image = "ui/images/search.svg",
- toolTip = "i18n::TableView.ToolBarWidget.NavigateToColumn.ToolTip")
+ toolTip = "i18n::TableView.ToolBarWidget.Find.ToolTip")
+ public void showFindMenu() {
+ if (columns.length == 0 || isDisposed() || findRemoved()) {
+ return;
+ }
+ Menu menu = new Menu(getShell(), SWT.POP_UP);
+ MenuItem findValueItem = new MenuItem(menu, SWT.NONE);
+ findValueItem.setText(
+ OsHelper.customizeMenuitemText(BaseMessages.getString(PKG,
"TableView.menu.FindValue")));
+ findValueItem.addListener(SWT.Selection, e -> findValue());
+
+ MenuItem findColumnItem = new MenuItem(menu, SWT.NONE);
+ findColumnItem.setText(
+ OsHelper.customizeMenuitemText(
+ BaseMessages.getString(PKG, "TableView.menu.NavigateToColumn")));
+ findColumnItem.addListener(SWT.Selection, e -> navigateToColumn());
+
+ menu.addListener(
+ SWT.Hide,
+ e ->
+ menu.getDisplay()
+ .asyncExec(
+ () -> {
+ if (!menu.isDisposed()) {
+ menu.dispose();
+ }
+ }));
+ menu.setLocation(getDisplay().getCursorLocation());
+ menu.setVisible(true);
+ }
+
+ private boolean isFindShortcut(KeyEvent e) {
+ return e.keyCode == 'f' && (e.stateMask & SWT.MOD1) != 0;
+ }
+
+ private boolean findRemoved() {
+ return removeToolItems != null &&
removeToolItems.contains(ID_TOOLBAR_NAVIGATE_TO_COLUMN);
+ }
+
+ /** Open the find-value dialog. Ctrl/Cmd-F and the toolbar menu both land
here. */
+ public void findValue() {
+ if (columns.length == 0 || isDisposed() || findRemoved()) {
+ return;
+ }
+ new TableViewFindDialog(getShell(), this).open();
+ }
+
+ /**
+ * Open a searchable column picker and scroll the table horizontally so the
chosen column is
+ * visible. Useful for wide tables (preview grids, field mapping dialogs,
etc.).
+ */
public void navigateToColumn() {
if (columns.length == 0) {
return;
@@ -4574,6 +4642,98 @@ public class TableView extends Composite {
}
}
+ /**
+ * Full cell text, visual column order, and the active cell. Commits an open
editor first so a
+ * value still being typed is part of the scan.
+ */
+ public TableViewFind.Grid captureFindGrid() {
+ if (table == null || table.isDisposed() || columns == null) {
+ return null;
+ }
+ applyAllChanges();
+ if (table.isDisposed()) {
+ return null;
+ }
+ int cols = columns.length;
+ int rowCount = table.getItemCount();
+ String[][] values = new String[rowCount][cols];
+ for (int row = 0; row < rowCount; row++) {
+ TableItem item = table.getItem(row);
+ for (int column = 0; column < cols; column++) {
+ String value = getCellValue(item, column + 1);
+ values[row][column] = value == null ? "" : value;
+ }
+ }
+ String[] names = new String[cols];
+ for (int column = 0; column < cols; column++) {
+ names[column] = Const.NVL(columns[column].getName(), "");
+ }
+ int activeRow = 0;
+ if (activeTableRow >= 0 && activeTableRow < rowCount) {
+ activeRow = activeTableRow;
+ }
+ int activeDataColumn = -1;
+ if (activeTableColumn >= 1 && activeTableColumn <= cols) {
+ activeDataColumn = activeTableColumn - 1;
+ }
+ return new TableViewFind.Grid(
+ values, visualDataColumnIndexes(), names, activeRow, activeDataColumn);
+ }
+
+ /**
+ * Show {@code row} and {@code dataColumn}. An editable text cell is opened
with its text
+ * selected. A read-only table or column is only scrolled into view. A
column click handler and a
+ * value that contains a line break are not opened either: both go through
{@link #edit(int,
+ * int)}, which would pop a dialog or the multi-line editor on top of Find.
+ */
+ public void revealFoundCell(int row, int dataColumn) {
+ if (table == null || table.isDisposed() || columns == null) {
+ return;
+ }
+ if (row < 0 || row >= table.getItemCount() || dataColumn < 0 || dataColumn
>= columns.length) {
+ return;
+ }
+ if (hiddenDataColumns.contains(dataColumn)) {
+ showDataColumn(dataColumn);
+ }
+ int tableColumnIndex = dataColumn + 1;
+ if (tableColumn != null && tableColumnIndex < tableColumn.length) {
+ TableColumn tableCol = tableColumn[tableColumnIndex];
+ if (tableCol != null && !tableCol.isDisposed()) {
+ table.showColumn(tableCol);
+ }
+ }
+ TableItem item = table.getItem(row);
+ table.showItem(item);
+ table.setSelection(row);
+ setPosition(row, tableColumnIndex);
+ // setPosition already recorded the cell. edit() is a click: it fires a
selection adapter and
+ // opens the multi-line pop-out, so those cells are only shown.
+ ColumnInfo colinfo = columns[dataColumn];
+ if (colinfo != null
+ && !readonly
+ && !colinfo.isReadOnly()
+ && colinfo.getSelectionAdapter() == null
+ && indexOfLineBreak(Const.NVL(getCellValue(item, tableColumnIndex),
"")) < 0) {
+ edit(row, tableColumnIndex);
+ }
+ }
+
+ private int[] visualDataColumnIndexes() {
+ int[] order = getColumnOrderSafe();
+ int[] data = new int[columns.length];
+ int count = 0;
+ for (int tableIndex : order) {
+ if (tableIndex >= 1 && tableIndex <= columns.length) {
+ data[count++] = tableIndex - 1;
+ }
+ }
+ if (count == data.length) {
+ return data;
+ }
+ return Arrays.copyOf(data, count);
+ }
+
@GuiToolbarElement(
root = ID_TOOLBAR,
id = ID_TOOLBAR_TABLE_VIEWS,
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewFind.java
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewFind.java
new file mode 100644
index 0000000000..aa0b41387a
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TableViewFind.java
@@ -0,0 +1,161 @@
+/*
+ * 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.Locale;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+/**
+ * Finds a cell in a table grid. The scan is row by row and, within a row,
left to right in the
+ * visual column order. Unchecked columns are skipped. There is no wrap.
+ */
+public final class TableViewFind {
+
+ private TableViewFind() {}
+
+ /** One cell, with {@code dataColumn} the data-column index (the {@code #}
column is not one). */
+ public record Hit(int row, int dataColumn) {}
+
+ /** A grid captured from a {@link TableView} for one search click. */
+ public record Grid(
+ String[][] cellValues,
+ int[] visualDataColumns,
+ String[] columnNames,
+ int activeRow,
+ int activeDataColumn) {}
+
+ public enum Status {
+ FOUND,
+ NOT_FOUND,
+ EMPTY_QUERY,
+ INVALID_REGEX
+ }
+
+ /** Outcome of one scan. {@code hit} is set only for {@link Status#FOUND}. */
+ public record Result(Status status, Hit hit, String regexMessage) {
+ public static Result found(Hit hit) {
+ return new Result(Status.FOUND, hit, null);
+ }
+
+ public static Result notFound() {
+ return new Result(Status.NOT_FOUND, null, null);
+ }
+
+ public static Result emptyQuery() {
+ return new Result(Status.EMPTY_QUERY, null, null);
+ }
+
+ public static Result invalidRegex(String message) {
+ return new Result(Status.INVALID_REGEX, null, message);
+ }
+ }
+
+ /**
+ * Scan {@code rows} for {@code query}.
+ *
+ * @param rows full cell text, {@code [row][dataColumn]}
+ * @param visualDataColumns data-column indexes in visual order
+ * @param included one flag per data column; unchecked columns are skipped
+ * @param startRow row to start from
+ * @param startDataColumn data column to start from, or {@code -1} to start
at the first checked
+ * column of {@code startRow}
+ * @param inclusive when false, the start cell itself is skipped
+ */
+ public static Result find(
+ String[][] rows,
+ int[] visualDataColumns,
+ boolean[] included,
+ String query,
+ boolean caseSensitive,
+ boolean regex,
+ int startRow,
+ int startDataColumn,
+ boolean inclusive) {
+ if (query == null || query.isEmpty()) {
+ return Result.emptyQuery();
+ }
+
+ Pattern pattern = null;
+ String needle = null;
+ if (regex) {
+ int flags = Pattern.DOTALL;
+ if (!caseSensitive) {
+ flags |= Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE;
+ }
+ try {
+ pattern = Pattern.compile(query, flags);
+ } catch (PatternSyntaxException e) {
+ String message = e.getDescription();
+ if (message == null || message.isEmpty()) {
+ message = e.getMessage();
+ }
+ return Result.invalidRegex(message == null ? "" : message);
+ }
+ } else {
+ needle = caseSensitive ? query : query.toLowerCase(Locale.ROOT);
+ }
+
+ if (rows == null || visualDataColumns == null || included == null ||
rows.length == 0) {
+ return Result.notFound();
+ }
+
+ int rowStart = Math.max(0, startRow);
+ boolean seeking = startDataColumn >= 0 && startRow >= 0;
+ for (int row = rowStart; row < rows.length; row++) {
+ String[] cells = rows[row];
+ boolean sawStart = false;
+ for (int dataColumn : visualDataColumns) {
+ boolean selected = dataColumn >= 0 && dataColumn < included.length &&
included[dataColumn];
+ if (seeking && row == rowStart && dataColumn == startDataColumn) {
+ sawStart = true;
+ seeking = false;
+ if (!inclusive || !selected) {
+ continue;
+ }
+ } else if (seeking && row == rowStart) {
+ continue;
+ }
+ if (!selected) {
+ continue;
+ }
+ String text = "";
+ if (cells != null && dataColumn < cells.length && cells[dataColumn] !=
null) {
+ text = cells[dataColumn];
+ }
+ if (pattern == null && !caseSensitive) {
+ text = text.toLowerCase(Locale.ROOT);
+ }
+ if (matches(text, needle, pattern)) {
+ return Result.found(new Hit(row, dataColumn));
+ }
+ }
+ if (seeking && row == rowStart && !sawStart) {
+ seeking = false;
+ }
+ }
+ return Result.notFound();
+ }
+
+ private static boolean matches(String text, String needle, Pattern pattern) {
+ if (pattern != null) {
+ return pattern.matcher(text).find();
+ }
+ return text.contains(needle);
+ }
+}
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 9500563d78..1eaf3adf1c 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
@@ -212,6 +212,20 @@ FindReplaceDialog.Shell.ReplaceTitle=Find and replace
FindReplaceDialog.Status.EmptyFind=Enter text to find
FindReplaceDialog.Status.NotFound=Text not found
FindReplaceDialog.Status.ReplaceAllCount={0} occurrence(s) replaced
+TableViewFindDialog.All.Label=All
+TableViewFindDialog.CaseSensitive.Label=Case sensitive
+TableViewFindDialog.Columns.Label=Columns
+TableViewFindDialog.Find.Label=Find
+TableViewFindDialog.FindFirst.Button=Find first
+TableViewFindDialog.FindNext.Button=Find next
+TableViewFindDialog.None.Label=None
+TableViewFindDialog.RegularExpression.Label=Regular expression
+TableViewFindDialog.Shell.Title=Find value
+TableViewFindDialog.Status.Empty=Enter a search string
+TableViewFindDialog.Status.Found=Found in row {0}, column {1}
+TableViewFindDialog.Status.InvalidRegex=Invalid regular expression\: {0}
+TableViewFindDialog.Status.NoMore=No more matches
+TableViewFindDialog.Status.NotFound=No matches
EnterOptionsDialog.MaxPreviewCellLength.Label=Maximum data grid cell length
EnterOptionsDialog.MaxPreviewCellLength.ToolTip=The maximum number of
characters shown in a single cell of a data grid (the row preview, the data
grid transform, the rows viewers). Longer values are only shortened for
display\: click a cell to see its full value in place, or use the expand icon
to open it in the multi-line editor. Copying, exporting and saving always use
the full value. Set to 0 to disable shortening. Configuration grids are never
shortened, and this has no effect in the [...]
EnterOptionsDialog.ShowPreviewLineBreaks.Label=Show line breaks as symbols in
data grid cells
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_de_DE.properties
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_de_DE.properties
index de825d9a6f..35d9b2b4af 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_de_DE.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_de_DE.properties
@@ -57,7 +57,7 @@ TableView.menu.CopyFieldToAllRows=Feldwert in alle Zeilen
kopieren
TableView.menu.CopyToClipboard=Ausgew\u00E4hlte Zeilen in Zwischenablage
kopieren STRG-C
TableView.menu.CutSelected=Ausgew\u00E4hlte Zeilen ausschneiden STRG-X
TableView.menu.DeleteSelected=Ausgew\u00E4hlte Zeilen l\u00F6schen DEL
-TableView.menu.FilteredSelection=Auswahl filtern\tSTRG-F
+TableView.menu.FilteredSelection=Auswahl filtern
TableView.menu.InsertAfterRow=Nach dieser Zeile einf\u00FCgen
TableView.menu.InsertBeforeRow=Vor dieser Zeile einf\u00FCgen
TableView.menu.KeepSelected=Nur ausgew\u00E4hlte Zeilen stehen lassen STRG-K
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
index a9e06f5580..4d85e4faf3 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_en_US.properties
@@ -64,14 +64,15 @@ TableView.menu.CopyFieldToAllRows=Copy field value to all
rows
TableView.menu.CopyToClipboard=Copy selected lines to clipboard\tCtrl-C
TableView.menu.CutSelected=Cut selected lines\tCtrl-X
TableView.menu.DeleteSelected=Delete selected lines\tDEL
-TableView.menu.FilteredSelection=Filtered selection\tCtrl-F
+TableView.menu.FilteredSelection=Filtered selection
TableView.menu.InsertAfterRow=Insert after this row
TableView.menu.InsertBeforeRow=Insert before this row
TableView.menu.KeepSelected=Keep only selected lines\tCtrl-K
TableView.menu.MoveDown=Move down\tCtrl-DOWN
TableView.menu.MoveUp=Move up\tCtrl-UP
TableView.menu.HideColumn=Hide column
-TableView.menu.NavigateToColumn=Navigate to column
+TableView.menu.FindValue=Find value\tCtrl-F
+TableView.menu.NavigateToColumn=Find column
TableView.menu.ShowAllColumns=Show all columns
TableView.menu.TableViews=Table views...
TableView.menu.OptimalSizeWithHeader=Optimal Column size incl. header\tF3
@@ -85,7 +86,7 @@ TableView.menu.UndoNotAvailable=Undo \: not available \tCtrl-Z
TableView.MessageBox.ClearTable.message=Are you sure you want to clear this
table? No undo is possible\!
TableView.MessageBox.ClearTable.title=Question
TableView.NavigateToColumn.Message=Select the column you want to navigate to
-TableView.NavigateToColumn.Title=Navigate to column
+TableView.NavigateToColumn.Title=Find column
TableView.ToolBarWidget.ClearAllRows.ToolTip=Clear all rows (after
confirmation)
TableView.ToolBarWidget.ClearSelection.ToolTip=Clear selection
TableView.ToolBarWidget.CopySelected.ToolTip=Copy selected rows to clipboard
@@ -98,6 +99,7 @@ TableView.ToolBarWidget.KeepSelected.ToolTip=Keep the
selected rows, delete the
TableView.WebNewlineHint.Label=* Line breaks aren't shown in table cells in
the web version (known limitation). Click the expand icon in a cell to view or
edit the full text.
TableView.ToolBarWidget.MoveRowsDown.ToolTip=Move selected rows down
TableView.ToolBarWidget.MoveRowsUp.ToolTip=Move selected rows up
+TableView.ToolBarWidget.Find.ToolTip=Find
TableView.ToolBarWidget.NavigateToColumn.ToolTip=Navigate to a column
TableView.ToolBarWidget.TableViews.ToolTip=Table views
TableView.tooltip.ExpandValue=Edit this value in a multi-line editor
(Shift+Enter)
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_es_AR.properties
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_es_AR.properties
index fd21afd6c6..e147ea8be5 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_es_AR.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_es_AR.properties
@@ -26,7 +26,7 @@ TableView.menu.CopyFieldToAllRows=Copiar el valor del campo a
todas las filas
TableView.menu.CopyToClipboard=Copiar filas seleccionadas al
portapapeles\tCtrl-C
TableView.menu.CutSelected=Cortar filas seleccionadas\tCtrl-X
TableView.menu.DeleteSelected=Borrar filas seleccionadas\tDEL
-TableView.menu.FilteredSelection=Seleccionar con filtro\tCtrl-F
+TableView.menu.FilteredSelection=Seleccionar con filtro
TableView.menu.InsertAfterRow=Insertar despu\u00e9s de esta fila
TableView.menu.InsertBeforeRow=Insertar antes de esta fila
TableView.menu.KeepSelected=Conservar s\u00f3lo las filas seleccionadas\tCtrl-K
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_es_ES.properties
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_es_ES.properties
index 59411792b7..e1324a8dd6 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_es_ES.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_es_ES.properties
@@ -52,7 +52,7 @@ TableView.menu.CopyFieldToAllRows=Copiar el valor del campo a
todas las filas
TableView.menu.CopyToClipboard=Copiar filas seleccionadas al
portapapeles\tCtrl-C
TableView.menu.CutSelected=Cortar filas seleccionadas\tCtrl-X
TableView.menu.DeleteSelected=Borrar filas seleccionadas\tDEL
-TableView.menu.FilteredSelection=Seleccionar con filtro\tCtrl-F
+TableView.menu.FilteredSelection=Seleccionar con filtro
TableView.menu.InsertAfterRow=Insertar despues de esta fila
TableView.menu.InsertBeforeRow=Insertar antes de esta fila
TableView.menu.KeepSelected=Conservar solo las filas seleccionadas\tCtrl-K
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_fr_FR.properties
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_fr_FR.properties
index 4d92947502..7787848e13 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_fr_FR.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_fr_FR.properties
@@ -53,7 +53,7 @@ TableView.menu.CopyFieldToAllRows=Copier la valeur du champ
vers toutes les lign
TableView.menu.CopyToClipboard=Copier les lignes s\u00E9lectionn\u00E9es vers
le presse-papier\tCtrl-C
TableView.menu.CutSelected=Couper les lignes s\u00E9lectionn\u00E9es\tCtrl-X
TableView.menu.DeleteSelected=Supprimer les lignes s\u00E9lectionn\u00E9es\tDEL
-TableView.menu.FilteredSelection=Filtrer la s\u00E9lection\tCtrl-F
+TableView.menu.FilteredSelection=Filtrer la s\u00E9lection
TableView.menu.InsertAfterRow=Ins\u00E9rer apr\u00E8s cette ligne
TableView.menu.InsertBeforeRow=Ins\u00E9rer avant cette ligne
TableView.menu.KeepSelected=Conserver uniquement les lignes
s\u00E9lectionn\u00E9es\tCtrl-K
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_it_IT.properties
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_it_IT.properties
index 1b434cffa7..ae3e2e69c8 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_it_IT.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_it_IT.properties
@@ -56,7 +56,7 @@ TableView.menu.CopyFieldToAllRows=Copia il valore del campo
in tutte le righe
TableView.menu.CopyToClipboard=Copia le linee selezionate negli appunti\tCtrl-C
TableView.menu.CutSelected=Taglia le linee selezionate\tCtrl-X
TableView.menu.DeleteSelected=Cancella le linee selezionate\tCANC
-TableView.menu.FilteredSelection=Selezione filtrata\tCtrl-F
+TableView.menu.FilteredSelection=Selezione filtrata
TableView.menu.InsertAfterRow=Inserisci dopo questa riga
TableView.menu.InsertBeforeRow=Inserisci prima di questa riga
TableView.menu.KeepSelected=Tieni solo le righe selezionate\tCtrl-K
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_ja_JP.properties
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_ja_JP.properties
index 37d2523e72..1a23c378dd 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_ja_JP.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_ja_JP.properties
@@ -54,7 +54,7 @@
TableView.menu.CopyFieldToAllRows=\u30B3\u30D4\u30FC\u3057\u305F\u5024\u3092\u30
TableView.menu.CopyToClipboard=\u9078\u629E\u3057\u305F\u884C\u3092\u30B3\u30D4\u30FC\tCtrl-C
TableView.menu.CutSelected=\u9078\u629E\u3057\u305F\u884C\u3092\u5207\u308A\u53D6\u308A\tCtrl-X
TableView.menu.DeleteSelected=\u9078\u629E\u3057\u305F\u884C\u3092\u524A\u9664\tDEL
-TableView.menu.FilteredSelection=\u6761\u4EF6\u4ED8\u304D\u3067\u884C\u3092\u9078\u629E\tCtrl-F
+TableView.menu.FilteredSelection=\u6761\u4EF6\u4ED8\u304D\u3067\u884C\u3092\u9078\u629E
TableView.menu.InsertAfterRow=\u4E0B\u65B9\u5411\u306B\u633F\u5165
TableView.menu.InsertBeforeRow=\u4E0A\u65B9\u5411\u306B\u633F\u5165
TableView.menu.KeepSelected=\u9078\u629E\u3057\u305F\u884C\u3092\u4FDD\u6301\u3059\u308B\tCtrl-K
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_ko_KR.properties
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_ko_KR.properties
index 4aa6e34e2a..4c44e4a45a 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_ko_KR.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_ko_KR.properties
@@ -54,7 +54,7 @@ TableView.menu.CopyFieldToAllRows=\uD544\uB4DC \uAC12\uC744
\uBAA8\uB4E0 \uB85C\
TableView.menu.CopyToClipboard=\uC120\uD0DD\uD55C \uB77C\uC778\uC744
\uD074\uB9BD\uBCF4\uB4DC\uC5D0 \uBCF5\uC0AC\tCtrl-C
TableView.menu.CutSelected=\uC120\uD0DD\uD55C \uB77C\uC778
\uC798\uB77C\uB0B4\uAE30\tCtrl-X
TableView.menu.DeleteSelected=\uC120\uD0DD\uD55C \uB77C\uC778
\uC9C0\uC6B0\uAE30\tDEL
-TableView.menu.FilteredSelection=\uD544\uD130\uB9C1 \uC120\uD0DD\tCtrl-F
+TableView.menu.FilteredSelection=\uD544\uD130\uB9C1 \uC120\uD0DD
TableView.menu.InsertAfterRow=\uD604\uC7AC \uB85C\uC6B0 \uB2E4\uC74C\uC5D0
\uC0BD\uC785
TableView.menu.InsertBeforeRow=\uD604\uC7AC \uB85C\uC6B0 \uC55E\uC5D0
\uC0BD\uC785
TableView.menu.KeepSelected=\uC120\uD0DD\uD55C \uB77C\uC778\uB9CC
\uC720\uC9C0\tCtrl-K
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_pt_BR.properties
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_pt_BR.properties
index 638b0387ef..bfcddb593f 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_pt_BR.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_pt_BR.properties
@@ -66,7 +66,7 @@ TableView.menu.CopyFieldToAllRows=Copia o valor do campo para
todas as linhas
TableView.menu.CopyToClipboard=Copiar as linhas selecionadas para a área de
transferência\tCtrl- C
TableView.menu.CutSelected=Cortar linhas selecionadas\tCtrl- X
TableView.menu.DeleteSelected=Apagar as linhas selecionadas\tDEL
-TableView.menu.FilteredSelection=Seleção filtrada\tCtrl-F
+TableView.menu.FilteredSelection=Seleção filtrada
TableView.menu.KeepSelected=Mantenha apenas as linhas selecionadas\tCtrl- K.
TableView.menu.MoveDown=Mover para baixo
TableView.menu.MoveUp=Mover para Cima
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_zh_CN.properties
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_zh_CN.properties
index 426f2ee4a6..5e7ec68880 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_zh_CN.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/widget/messages/messages_zh_CN.properties
@@ -53,7 +53,7 @@
TableView.menu.CopyFieldToAllRows=\u590D\u5236\u5B57\u6BB5\u503C\u5230\u6240\u67
TableView.menu.CopyToClipboard=\u590D\u5236\u9009\u4E2D\u884C\u5230\u526A\u8D34\u677F\tCtrl-C
TableView.menu.CutSelected=\u526A\u5207\u9009\u4E2D\u7684\u884C\tCtrl-X
TableView.menu.DeleteSelected=\u5220\u9664\u9009\u4E2D\u7684\u884C\tDEL
-TableView.menu.FilteredSelection=\u8FC7\u6EE4\u9009\u62E9\tCtrl-F
+TableView.menu.FilteredSelection=\u8FC7\u6EE4\u9009\u62E9
TableView.menu.InsertAfterRow=\u5F53\u524D\u884C\u540E\u9762\u63D2\u5165
TableView.menu.InsertBeforeRow=\u5F53\u524D\u884C\u524D\u9762\u63D2\u5165
TableView.menu.KeepSelected=\u53EA\u4FDD\u7559\u9009\u4E2D\u7684\u884C\tCtrl-K
diff --git
a/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewFindTest.java
b/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewFindTest.java
new file mode 100644
index 0000000000..a3446ad50d
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/core/widget/TableViewFindTest.java
@@ -0,0 +1,178 @@
+/*
+ * 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.assertNull;
+
+import org.apache.hop.ui.core.widget.TableViewFind.Hit;
+import org.apache.hop.ui.core.widget.TableViewFind.Result;
+import org.apache.hop.ui.core.widget.TableViewFind.Status;
+import org.junit.jupiter.api.Test;
+
+class TableViewFindTest {
+
+ private static final String[][] GRID = {
+ {"id", "Ada"},
+ {"id", "Grace"},
+ {"code", "ada lovelace"},
+ };
+
+ private static final int[] LEFT_TO_RIGHT = {0, 1};
+
+ private static final boolean[] BOTH = {true, true};
+
+ @Test
+ void findFirstMatchesSubstringIgnoringCase() {
+ Result result = find(GRID, LEFT_TO_RIGHT, BOTH, "ada", false, false, 0,
-1, true);
+
+ assertEquals(Status.FOUND, result.status());
+ assertEquals(new Hit(0, 1), result.hit());
+ }
+
+ @Test
+ void caseSensitiveSearchSkipsADifferentCase() {
+ Result result = find(GRID, LEFT_TO_RIGHT, BOTH, "ada", true, false, 0, -1,
true);
+
+ assertEquals(new Hit(2, 1), result.hit());
+ }
+
+ @Test
+ void regularExpressionMatchesInsideTheCell() {
+ Result result = find(GRID, LEFT_TO_RIGHT, BOTH, "ad.", false, true, 0, -1,
true);
+
+ assertEquals(new Hit(0, 1), result.hit());
+ }
+
+ @Test
+ void regularExpressionCanSpanALineBreak() {
+ String[][] rows = {{"a\nb"}};
+ Result result = find(rows, new int[] {0}, new boolean[] {true}, "a.b",
true, true, 0, -1, true);
+
+ assertEquals(new Hit(0, 0), result.hit());
+ }
+
+ @Test
+ void regularExpressionHonorsCase() {
+ Result insensitive = find(GRID, LEFT_TO_RIGHT, BOTH, "grace", false, true,
0, -1, true);
+ Result sensitive = find(GRID, LEFT_TO_RIGHT, BOTH, "grace", true, true, 0,
-1, true);
+
+ assertEquals(new Hit(1, 1), insensitive.hit());
+ assertEquals(Status.NOT_FOUND, sensitive.status());
+ assertNull(sensitive.hit());
+ }
+
+ @Test
+ void invalidRegularExpressionDoesNotMatch() {
+ Result result = find(GRID, LEFT_TO_RIGHT, BOTH, "[", true, true, 0, -1,
true);
+
+ assertEquals(Status.INVALID_REGEX, result.status());
+ assertNull(result.hit());
+ }
+
+ @Test
+ void emptyQueryDoesNotScan() {
+ Result result = find(GRID, LEFT_TO_RIGHT, BOTH, "", false, false, 0, -1,
true);
+
+ assertEquals(Status.EMPTY_QUERY, result.status());
+ }
+
+ @Test
+ void uncheckedColumnIsSkipped() {
+ Result hidden =
+ find(GRID, LEFT_TO_RIGHT, new boolean[] {true, false}, "Ada", true,
false, 0, -1, true);
+ Result visible =
+ find(GRID, LEFT_TO_RIGHT, new boolean[] {false, true}, "Ada", true,
false, 0, -1, true);
+
+ assertEquals(Status.NOT_FOUND, hidden.status());
+ assertEquals(new Hit(0, 1), visible.hit());
+ }
+
+ @Test
+ void visualOrderDecidesWhichColumnMatchesFirst() {
+ String[][] rows = {{"alpha-id", "id-beta"}};
+ Result rightFirst = find(rows, new int[] {1, 0}, BOTH, "id", true, false,
0, -1, true);
+ Result leftFirst = find(rows, LEFT_TO_RIGHT, BOTH, "id", true, false, 0,
-1, true);
+
+ assertEquals(new Hit(0, 1), rightFirst.hit());
+ assertEquals(new Hit(0, 0), leftFirst.hit());
+ }
+
+ @Test
+ void findNextIncludesTheStartCellThenMovesPastIt() {
+ Result first = find(GRID, LEFT_TO_RIGHT, BOTH, "id", true, false, 0, 0,
true);
+ Result next =
+ find(
+ GRID,
+ LEFT_TO_RIGHT,
+ BOTH,
+ "id",
+ true,
+ false,
+ first.hit().row(),
+ first.hit().dataColumn(),
+ false);
+
+ assertEquals(new Hit(0, 0), first.hit());
+ assertEquals(new Hit(1, 0), next.hit());
+ }
+
+ @Test
+ void searchDoesNotWrap() {
+ Result result = find(GRID, LEFT_TO_RIGHT, BOTH, "id", true, false, 2, 0,
false);
+
+ assertEquals(Status.NOT_FOUND, result.status());
+ }
+
+ @Test
+ void nullCellDoesNotMatchAndDoesNotFail() {
+ String[][] rows = {{null, "needle"}};
+ Result result = find(rows, LEFT_TO_RIGHT, BOTH, "needle", true, false, 0,
-1, true);
+
+ assertEquals(new Hit(0, 1), result.hit());
+ }
+
+ @Test
+ void missingStartColumnContinuesOnTheNextRow() {
+ Result result = find(GRID, new int[] {0}, BOTH, "code", true, false, 0, 1,
false);
+
+ assertEquals(new Hit(2, 0), result.hit());
+ }
+
+ private static Result find(
+ String[][] rows,
+ int[] visualDataColumns,
+ boolean[] included,
+ String query,
+ boolean caseSensitive,
+ boolean regex,
+ int startRow,
+ int startDataColumn,
+ boolean inclusive) {
+ return TableViewFind.find(
+ rows,
+ visualDataColumns,
+ included,
+ query,
+ caseSensitive,
+ regex,
+ startRow,
+ startDataColumn,
+ inclusive);
+ }
+}