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

Reply via email to