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 0dd7ad630e Fix #8360: read-only grid selects the row on click and 
keeps its context menu (#8530)
0dd7ad630e is described below

commit 0dd7ad630e464e336bea3cbe89d50dc8adf9a5ff
Author: Frank Skorgen <[email protected]>
AuthorDate: Wed Oct 14 16:08:59 2026 +0200

    Fix #8360: read-only grid selects the row on click and keeps its context 
menu (#8530)
    
    * Fix #8360: read-only grid selects the row on click, double-click opens 
the cell
    
    * rebase and fix remarks
    
    * fix remarks add tests
    
    ---------
    
    Co-authored-by: Hans Van Akelyen <[email protected]>
---
 .../ui/core/widget/TableViewReadOnlyGridTest.java  | 408 +++++++++++++++++++++
 .../org/apache/hop/ui/core/widget/TableView.java   | 188 ++++++++--
 .../hop/ui/pipeline/debug/PipelineDebugDialog.java |  26 +-
 .../core/widget/messages/messages_en_US.properties |   1 +
 4 files changed, 574 insertions(+), 49 deletions(-)

diff --git 
a/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewReadOnlyGridTest.java
 
b/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewReadOnlyGridTest.java
new file mode 100644
index 0000000000..631cf1d468
--- /dev/null
+++ 
b/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewReadOnlyGridTest.java
@@ -0,0 +1,408 @@
+/*
+ * 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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Method;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.core.variables.Variables;
+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.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Menu;
+import org.eclipse.swt.widgets.MenuItem;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swt.widgets.ToolItem;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * A read-only grid ({@link TableView#setReadonly(boolean)}): a click selects 
the row, a
+ * double-click opens the cell as a viewer that never writes back, and the 
selection and copy
+ * actions work.
+ *
+ * <p>Clicks and menu opens are sent straight to the table's listeners. That 
is also what proves the
+ * grid does its own bookkeeping, such as refreshing the toolbar, without 
counting on a native
+ * {@link SWT#Selection} that platforms do not reliably send after the row was 
selected in code.
+ */
+@Tag("uitest")
+class TableViewReadOnlyGridTest extends SwtBotTestBase {
+
+  /**
+   * The grid's toolbar items are its own {@code @GuiToolbarElement} methods, 
which the Hop GUI
+   * registers at startup. Register them the same way here so the grid gets 
its toolbar.
+   */
+  @BeforeAll
+  static void registerTableViewToolbar() {
+    GuiRegistry registry = GuiRegistry.getInstance();
+    for (Method method : TableView.class.getDeclaredMethods()) {
+      GuiToolbarElement element = 
method.getAnnotation(GuiToolbarElement.class);
+      if (element != null && registry.findGuiToolbarItem(element.root(), 
element.id()) == null) {
+        registry.addGuiToolbarElement(
+            TableView.class.getName(), element, method, 
TableView.class.getClassLoader());
+      }
+    }
+  }
+
+  @Test
+  void clickSelectsTheRowAndEnablesCopyWithoutOpeningAnEditor() {
+    AtomicReference<TableView> viewRef = new AtomicReference<>();
+    withScene(
+        shell -> viewRef.set(readOnlyGrid(shell, plainColumns(), null)),
+        bot -> {
+          waitForLayout(viewRef.get());
+          Object[] state =
+              onUi(
+                  () -> {
+                    TableView view = viewRef.get();
+                    ToolItem copy =
+                        
view.getToolbarWidgets().findToolItem(TableView.ID_TOOLBAR_COPY_SELECTED);
+                    assertNotNull(copy, "the Copy toolbar item");
+                    boolean copyEnabledBefore = copy.isEnabled();
+                    sendMouse(view, SWT.MouseDown, 1, 1);
+                    return new Object[] {
+                      copyEnabledBefore,
+                      view.table.getSelectionIndex(),
+                      copy.isEnabled(),
+                      findText(view.table)
+                    };
+                  });
+          assertFalse((Boolean) state[0], "Copy starts disabled");
+          assertEquals(1, state[1]);
+          assertTrue((Boolean) state[2], "Copy is enabled once the click 
selected a row");
+          assertNull(state[3], "a single click opens no editor");
+        });
+  }
+
+  @Test
+  void doubleClickOpensAViewerThatNeverWritesBack() {
+    AtomicReference<TableView> viewRef = new AtomicReference<>();
+    AtomicInteger modifications = new AtomicInteger();
+    withScene(
+        shell ->
+            viewRef.set(readOnlyGrid(shell, plainColumns(), e -> 
modifications.incrementAndGet())),
+        bot -> {
+          waitForLayout(viewRef.get());
+          Object[] state =
+              onUi(
+                  () -> {
+                    TableView view = viewRef.get();
+                    doubleClick(view, 0, 2);
+                    Text viewer = findText(view.table);
+                    assertNotNull(viewer, "a double-click opens the cell");
+                    boolean readOnlyStyle = (viewer.getStyle() & 
SWT.READ_ONLY) != 0;
+                    String shown = viewer.getText();
+                    // Even text put into the viewer in code must not reach 
the cell.
+                    viewer.setText("changed");
+                    // Clicking another cell closes the viewer the way any 
open editor is closed.
+                    sendMouse(view, SWT.MouseDown, 1, 1);
+                    return new Object[] {
+                      readOnlyStyle, shown, view.table.getItem(0).getText(2), 
findText(view.table)
+                    };
+                  });
+          assertTrue((Boolean) state[0], "the viewer can't be typed in");
+          assertEquals("b0", state[1]);
+          assertEquals("b0", state[2], "the cell keeps its value");
+          assertNull(state[3], "the viewer is gone");
+          assertEquals(0, modifications.get(), "nothing reported a change");
+        });
+  }
+
+  @Test
+  void doubleClickIsLeftToAnOwnerThatOpensTheRow() {
+    AtomicReference<TableView> viewRef = new AtomicReference<>();
+    withScene(
+        shell -> {
+          TableView view = readOnlyGrid(shell, plainColumns(), null);
+          // An owner that picks or opens the double-clicked row, like the 
pipeline grid does.
+          view.table.addListener(SWT.DefaultSelection, e -> {});
+          viewRef.set(view);
+        },
+        bot -> {
+          waitForLayout(viewRef.get());
+          Text viewer =
+              onUi(
+                  () -> {
+                    TableView view = viewRef.get();
+                    doubleClick(view, 0, 1);
+                    return findText(view.table);
+                  });
+          assertNull(viewer, "the owner picks or opens the row, the grid opens 
no cell");
+        });
+  }
+
+  @Test
+  void selectionAdapterColumnOpensTheViewerInsteadOfTheAdapter() {
+    AtomicReference<TableView> viewRef = new AtomicReference<>();
+    AtomicInteger adapterCalls = new AtomicInteger();
+    String formula = "IF([amount] > 1000; \"large\"; IF([amount] > 100; 
\"medium\"; \"small\"))";
+    withScene(
+        shell -> {
+          ColumnInfo[] columns = plainColumns();
+          columns[1].setSelectionAdapter(
+              new SelectionAdapter() {
+                @Override
+                public void widgetSelected(SelectionEvent e) {
+                  adapterCalls.incrementAndGet();
+                }
+              });
+          TableView view = readOnlyGrid(shell, columns, null);
+          view.table.getItem(0).setText(2, formula);
+          viewRef.set(view);
+        },
+        bot -> {
+          waitForLayout(viewRef.get());
+          Object[] state =
+              onUi(
+                  () -> {
+                    TableView view = viewRef.get();
+                    doubleClick(view, 0, 2);
+                    Text viewer = findText(view.table);
+                    return new Object[] {
+                      viewer == null ? null : viewer.getText(),
+                      viewer != null && (viewer.getStyle() & SWT.READ_ONLY) != 0
+                    };
+                  });
+          assertEquals(0, adapterCalls.get(), "the adapter edits the cell, so 
it is not run");
+          assertEquals(formula, state[0], "the full value is shown instead");
+          assertTrue((Boolean) state[1]);
+        });
+  }
+
+  @Test
+  void copyCellValueCopiesTheStoredValueAndNothingForANull() {
+    AtomicReference<TableView> viewRef = new AtomicReference<>();
+    String multiLine = "first line\nsecond line";
+    withScene(
+        shell -> {
+          TableView view = readOnlyGrid(shell, plainColumns(), null);
+          view.setShortenDisplayedValues(true);
+          TableItem row = view.table.getItem(0);
+          view.setCellValue(row, 1, multiLine);
+          // A NULL, drawn the way the data grids draw it, and a value that 
only reads "<null>".
+          row.setText(2, "<null>");
+          row.setForeground(2, GuiResource.getInstance().getColorBlue());
+          view.table.getItem(1).setText(2, "<null>");
+          viewRef.set(view);
+        },
+        bot -> {
+          waitForLayout(viewRef.get());
+          String[] copied =
+              onUi(
+                  () -> {
+                    TableView view = viewRef.get();
+                    String displayed = view.table.getItem(0).getText(1);
+                    sendMouse(view, SWT.MouseDown, 0, 1);
+                    String stored = view.getActiveCellValueToCopy();
+                    sendMouse(view, SWT.MouseDown, 0, 2);
+                    String nullValue = view.getActiveCellValueToCopy();
+                    sendMouse(view, SWT.MouseDown, 1, 2);
+                    String text = view.getActiveCellValueToCopy();
+                    return new String[] {displayed, stored, nullValue, text};
+                  });
+          assertFalse(multiLine.equals(copied[0]), "the cell shows a shortened 
value");
+          assertEquals(multiLine, copied[1], "the stored value is copied, not 
the display text");
+          assertEquals("", copied[2], "a NULL is copied as nothing");
+          assertEquals("<null>", copied[3], "text that only reads <null> is 
copied as it is");
+        });
+  }
+
+  @Test
+  void copyCellValueOnlyActsOnTheCellTheMenuWasOpenedOn() {
+    AtomicReference<TableView> viewRef = new AtomicReference<>();
+    withScene(
+        shell -> viewRef.set(readOnlyGrid(shell, plainColumns(), null)),
+        bot -> {
+          waitForLayout(viewRef.get());
+          boolean[] enabled =
+              onUi(
+                  () -> {
+                    TableView view = viewRef.get();
+                    sendMouse(view, SWT.MouseDown, 0, 1);
+                    Point onCell = center(view.table.getItem(0).getBounds(1));
+                    Rectangle lastRow = view.table.getItem(1).getBounds(1);
+                    Point belowRows = new Point(onCell.x, lastRow.y + 
lastRow.height * 3);
+                    Point otherCell = 
center(view.table.getItem(1).getBounds(2));
+                    return new boolean[] {
+                      copyCellEnabled(view, SWT.MENU_MOUSE, onCell),
+                      copyCellEnabled(view, SWT.MENU_MOUSE, belowRows),
+                      // Shift/ctrl right-click does not move the active cell.
+                      copyCellEnabled(view, SWT.MENU_MOUSE, otherCell),
+                      copyCellEnabled(view, SWT.MENU_KEYBOARD, new Point(-1, 
-1))
+                    };
+                  });
+          assertTrue(enabled[0], "menu opened on the active cell");
+          assertFalse(enabled[1], "menu opened below the rows");
+          assertFalse(enabled[2], "menu opened on another cell than the active 
one");
+          assertTrue(enabled[3], "menu opened from the keyboard acts on the 
active cell");
+        });
+  }
+
+  /** Open the row context menu the way SWT does and report whether Copy cell 
value is enabled. */
+  private static boolean copyCellEnabled(TableView view, int detail, Point 
tablePoint) {
+    Event detect = new Event();
+    Point displayPoint = view.table.toDisplay(tablePoint);
+    detect.x = displayPoint.x;
+    detect.y = displayPoint.y;
+    detect.detail = detail;
+    view.table.notifyListeners(SWT.MenuDetect, detect);
+    Menu menu = view.table.getMenu();
+    menu.notifyListeners(SWT.Show, new Event());
+    String label = BaseMessages.getString(TableView.class, 
"TableView.menu.CopyCellValue");
+    for (MenuItem item : menu.getItems()) {
+      if (label.equals(item.getText())) {
+        return item.isEnabled();
+      }
+    }
+    throw new AssertionError("no '" + label + "' item in the row menu");
+  }
+
+  private static ColumnInfo[] plainColumns() {
+    return new ColumnInfo[] {
+      new ColumnInfo("Name", ColumnInfo.COLUMN_TYPE_TEXT, false, false),
+      new ColumnInfo("Value", ColumnInfo.COLUMN_TYPE_TEXT, false, false),
+    };
+  }
+
+  /** Plain, editable columns in a grid that is read-only as a whole, as 
BaseDialog makes it. */
+  private static TableView readOnlyGrid(
+      Composite parent, ColumnInfo[] columns, ModifyListener modifyListener) {
+    parent.setLayout(new FillLayout());
+    // Room for the toolbar and both rows, so nothing has to scroll.
+    parent.setSize(640, 400);
+    TableView view =
+        new TableView(
+            new Variables(),
+            parent,
+            SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+            columns,
+            2,
+            modifyListener,
+            PropsUi.getInstance());
+    for (int row = 0; row < 2; row++) {
+      view.table.getItem(row).setText(1, "a" + row);
+      view.table.getItem(row).setText(2, "b" + row);
+    }
+    view.setReadonly(true);
+    return view;
+  }
+
+  /**
+   * GTK sizes a shell some time after it opens. Until then the table has no 
client area and a click
+   * on any row but the first finds no visible row to land in, so wait for the 
rows to show.
+   */
+  private static void waitForLayout(TableView view) {
+    long deadline = System.currentTimeMillis() + 10_000;
+    while (!onUi(() -> rowsVisible(view))) {
+      if (System.currentTimeMillis() > deadline) {
+        throw new AssertionError("the grid was never laid out: " + 
onUi(view.table::getClientArea));
+      }
+      try {
+        Thread.sleep(20);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new AssertionError(e);
+      }
+    }
+  }
+
+  private static boolean rowsVisible(TableView view) {
+    Rectangle clientArea = view.table.getClientArea();
+    Rectangle lastRow = view.table.getItem(view.table.getItemCount() - 
1).getBounds(2);
+    return clientArea.width >= lastRow.x + lastRow.width
+        && clientArea.y + clientArea.height >= lastRow.y + lastRow.height;
+  }
+
+  private static void doubleClick(TableView view, int row, int column) {
+    sendMouse(view, SWT.MouseDown, row, column);
+    sendMouse(view, SWT.MouseDoubleClick, row, column);
+  }
+
+  private static void sendMouse(TableView view, int type, int row, int column) 
{
+    Point point = center(view.table.getItem(row).getBounds(column));
+    Event event = new Event();
+    event.button = 1;
+    event.x = point.x;
+    event.y = point.y;
+    view.table.notifyListeners(type, event);
+  }
+
+  private static Point center(Rectangle bounds) {
+    return new Point(bounds.x + bounds.width / 2, bounds.y + bounds.height / 
2);
+  }
+
+  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<>();
+    AtomicReference<Error> error = new AtomicReference<>();
+    display.syncExec(
+        () -> {
+          try {
+            result.set(supplier.get());
+          } catch (RuntimeException e) {
+            failure.set(e);
+          } catch (Error e) {
+            error.set(e);
+          }
+        });
+    if (failure.get() != null) {
+      throw failure.get();
+    }
+    if (error.get() != null) {
+      throw error.get();
+    }
+    return result.get();
+  }
+}
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 15af344096..7470574d21 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
@@ -183,6 +183,12 @@ public class TableView extends Composite {
   private final ColumnInfo[] columns;
   @Getter @Setter private boolean readonly;
 
+  /**
+   * Where the row context menu was last opened with the mouse, in table 
coordinates, or null when
+   * it was opened from the keyboard. Set on every {@link SWT#MenuDetect}, 
whatever the modifiers.
+   */
+  private Point contextMenuPoint;
+
   /**
    * Draw long / multi-line text cells shortened and single-lined (see {@link
    * #formatCellValueForDisplay(String)}). Off by default: it was added to 
keep the data-heavy grids
@@ -767,6 +773,30 @@ public class TableView extends Composite {
 
   private MouseListener createTableMouseListener() {
     return new MouseAdapter() {
+      // A single click selects the row in a read-only grid, so double-click 
is what opens the
+      // view-only editor: the one place a long or multi-line value can be 
read in full and its
+      // text selected.
+      @Override
+      public void mouseDoubleClick(MouseEvent event) {
+        if (!readonly
+            || event.button != 1
+            || activeTableItem == null
+            || activeTableItem.isDisposed()
+            || activeTableColumn < 1) {
+          return;
+        }
+        // A click beside or below the rows leaves the active cell where it 
was: only open the cell
+        // the click actually landed in.
+        if (!activeTableItem.getBounds(activeTableColumn).contains(event.x, 
event.y)) {
+          return;
+        }
+        // Where the owner picks or opens the double-clicked row, that is all 
a double-click does.
+        if (ownerHandlesRowActivation()) {
+          return;
+        }
+        edit(activeTableRow, activeTableColumn);
+      }
+
       @Override
       public void mouseDown(MouseEvent event) {
         // Commit whatever cell is being edited before the click moves the 
active cell somewhere
@@ -869,7 +899,7 @@ public class TableView extends Composite {
       }
       final int rowNr = table.indexOf(row);
       final int colNr = activeTableColumn;
-      if (isColumnReadOnly(colNr)) {
+      if (isViewOnly(colNr)) {
         // Nothing is typed into a view-only editor, so there is nothing to 
write back.
         return;
       }
@@ -1346,7 +1376,7 @@ public class TableView extends Composite {
         final Composite fholder = inlineEditorHolder;
 
         // The editor on a read-only cell is a viewer: it just goes away, it 
never writes back.
-        if (isColumnReadOnly(colNr)) {
+        if (isViewOnly(colNr)) {
           disposeInlineEditor(ftext, fholder);
           return;
         }
@@ -1480,7 +1510,6 @@ public class TableView extends Composite {
           OsHelper.customizeMenuitemText(BaseMessages.getString(PKG, 
"TableView.menu.SelectAll")));
       miSelectAll.setImage(GuiResource.getInstance().getImageSelectAll());
       miSelectAll.addListener(SWT.Selection, e -> selectAll());
-      miSelectAll.setEnabled(!readonly);
     }
 
     if (!removeToolItems.contains(ID_TOOLBAR_CLEAR_SELECTION)) {
@@ -1490,7 +1519,6 @@ public class TableView extends Composite {
               BaseMessages.getString(PKG, "TableView.menu.ClearSelection")));
       miUnselectAll.setImage(GuiResource.getInstance().getImageUnselectAll());
       miUnselectAll.addListener(SWT.Selection, e -> unselectAll());
-      miUnselectAll.setEnabled(!readonly);
     }
 
     if (!removeToolItems.contains(ID_TOOLBAR_FILTERED_SELECTION)) {
@@ -1499,7 +1527,6 @@ public class TableView extends Composite {
           OsHelper.customizeMenuitemText(
               BaseMessages.getString(PKG, 
"TableView.menu.FilteredSelection")));
       miFilter.addListener(SWT.Selection, e -> setFilter());
-      miFilter.setEnabled(!readonly);
     }
 
     if (!removeToolItems.contains(ID_TOOLBAR_NAVIGATE_TO_COLUMN)) {
@@ -1533,7 +1560,16 @@ public class TableView extends Composite {
               BaseMessages.getString(PKG, "TableView.menu.CopyToClipboard")));
       miCopy.setImage(GuiResource.getInstance().getImageCopy());
       miCopy.addListener(SWT.Selection, e -> clipSelected());
-      miCopy.setEnabled(!readonly);
+
+      MenuItem miCopyCell = new MenuItem(mRow, SWT.NONE);
+      miCopyCell.setText(
+          OsHelper.customizeMenuitemText(
+              BaseMessages.getString(PKG, "TableView.menu.CopyCellValue")));
+      miCopyCell.setImage(GuiResource.getInstance().getImageCopy());
+      miCopyCell.addListener(SWT.Selection, e -> clipCell());
+      mRow.addListener(
+          SWT.Show,
+          e -> miCopyCell.setEnabled(hasActiveValueCell() && 
isContextMenuOnActiveCell()));
     }
 
     if (!removeToolItems.contains(ID_TOOLBAR_PASTE_TO_TABLE)) {
@@ -2029,12 +2065,30 @@ public class TableView extends Composite {
     disposeInlineEditor(text, inlineEditorHolder);
   }
 
-  /** True when the given (1-based) table column is read-only, so its editor 
is view-only. */
-  private boolean isColumnReadOnly(int colNr) {
-    return colNr > 0
-        && colNr - 1 < columns.length
-        && columns[colNr - 1] != null
-        && columns[colNr - 1].isReadOnly();
+  /**
+   * True when the editor on the given (1-based) table column is only a 
viewer: the whole grid is
+   * read-only, or that column is.
+   */
+  private boolean isViewOnly(int colNr) {
+    return readonly
+        || (colNr > 0
+            && colNr - 1 < columns.length
+            && columns[colNr - 1] != null
+            && columns[colNr - 1].isReadOnly());
+  }
+
+  /**
+   * True when the grid's owner already acts on a double-clicked row, to pick 
or open it.
+   *
+   * <p>{@code table.addSelectionListener(...)} and {@code 
table.addMouseListener(...)} also listen
+   * for {@link SWT#DefaultSelection} and {@link SWT#MouseDoubleClick}. An 
owner that only reacts to
+   * a selection or a mouse-down should use {@code 
table.addListener(SWT.Selection, ...)}, or a
+   * double-click in its read-only grid no longer opens the cell.
+   */
+  private boolean ownerHandlesRowActivation() {
+    // Our own mouse listener is one of the double-click listeners, so look 
for another.
+    return table.isListening(SWT.DefaultSelection)
+        || table.getListeners(SWT.MouseDoubleClick).length > 1;
   }
 
   /** Variant for call sites that captured the editor and its holder before 
going asynchronous. */
@@ -2070,7 +2124,7 @@ public class TableView extends Composite {
       return;
     }
     // The editor on a read-only cell is a viewer: take it down again without 
touching the value.
-    if (isColumnReadOnly(colNr)) {
+    if (isViewOnly(colNr)) {
       disposeInlineEditor();
       table.setFocus();
       return;
@@ -2162,11 +2216,16 @@ public class TableView extends Composite {
       return;
     }
 
-    if (activeTableColumn > 0) {
+    if (activeTableColumn > 0 && !readonly) {
       edit(activeTableRow, activeTableColumn);
-    } else {
-      selectRows(activeTableRow, activeTableRow);
+      return;
     }
+
+    // A click in a read-only grid, or on the row number, selects the row. 
Table.select() sends no
+    // SWT.Selection and the native click usually sends none either once the 
row is selected, so
+    // refresh the toolbar here, as edit(), selectAll() and unselectAll() do.
+    selectRows(activeTableRow, activeTableRow);
+    enableToolbarButtons();
   }
 
   /**
@@ -2213,9 +2272,9 @@ public class TableView extends Composite {
       multilineShell.setFocus();
       return;
     }
-    // On a read-only column the pop-out is a value viewer: same box, but it 
can't be typed in and
-    // it never writes back to the cell.
-    final boolean viewOnly = colinfo.isReadOnly();
+    // On a read-only column or grid the pop-out is a value viewer: same box, 
but it can't be typed
+    // in and it never writes back to the cell.
+    final boolean viewOnly = isViewOnly(colNr);
     // If an inline editor is already open on this cell, carry over its 
current (possibly edited)
     // text; otherwise start from the stored cell value. Read it before 
disposing the inline editor.
     String seed;
@@ -2912,6 +2971,49 @@ public class TableView extends Composite {
     clipboard.setContents(new String[] {clip}, new Transfer[] {tran});
   }
 
+  public void clipCell() {
+    String value = getActiveCellValueToCopy();
+    if (value != null) {
+      GuiResource.getInstance().toClipboard(value);
+    }
+  }
+
+  /**
+   * The stored value of the active cell as Copy cell value puts it on the 
clipboard: the full value
+   * rather than the shortened display text, and nothing for a NULL. Null when 
no value cell is
+   * active.
+   */
+  String getActiveCellValueToCopy() {
+    if (!hasActiveValueCell()) {
+      return null;
+    }
+    String value = getCellValue(activeTableItem, activeTableColumn);
+    return isNullPlaceholder(activeTableItem, activeTableColumn, value) ? "" : 
value;
+  }
+
+  /** A value cell (not the row number) is active. */
+  private boolean hasActiveValueCell() {
+    return activeTableItem != null
+        && !activeTableItem.isDisposed()
+        && activeTableColumn > 0
+        && activeTableColumn < table.getColumnCount();
+  }
+
+  /**
+   * The row context menu was opened from the keyboard, or with the mouse on 
the active cell. A
+   * right-click beside or below the rows, or a shift/ctrl right-click on 
another cell, leaves the
+   * active cell where it was, so the menu has no cell to act on.
+   */
+  private boolean isContextMenuOnActiveCell() {
+    return contextMenuPoint == null
+        || 
activeTableItem.getBounds(activeTableColumn).contains(contextMenuPoint);
+  }
+
+  /** The cell shows the blue {@code <null>} placeholder a grid draws for a 
NULL value. */
+  private boolean isNullPlaceholder(TableItem item, int colNr, String value) {
+    return "<null>".equals(value) && 
nullTextColor.equals(item.getForeground(colNr));
+  }
+
   private String getSelectedText() {
     StringBuilder selection = new StringBuilder();
 
@@ -2941,11 +3043,8 @@ public class TableView extends Composite {
           selection.append(CLIPBOARD_DELIMITER);
         }
         String value = getCellValue(ti, c);
-        if (StringUtils.isNotEmpty(value)) {
-          Color textColor = ti.getForeground(c);
-          if (!nullTextColor.equals(textColor) || !"<null>".equals(value)) {
-            selection.append(value);
-          }
+        if (StringUtils.isNotEmpty(value) && !isNullPlaceholder(ti, c, value)) 
{
+          selection.append(value);
         }
       }
       selection.append(Const.CR);
@@ -3281,10 +3380,12 @@ public class TableView extends Composite {
 
     // A read-only cell can't be edited, but its value still has to be 
readable and selectable: it
     // is drawn shortened and single-lined, so without an editor there is no 
way to see the rest of
-    // it. Give it the same inline editor (and expand icon), only view-only. 
Columns that hand their
-    // click to a selection adapter keep doing nothing, as before.
-    final boolean viewOnly = colinfo.isReadOnly();
-    if (viewOnly && colinfo.getSelectionAdapter() != null) {
+    // it. Give it the same inline editor (and expand icon), only view-only. A 
read-only column that
+    // hands its click to a selection adapter keeps doing nothing, as before. 
A read-only grid makes
+    // every one of its cells read-only, and there the adapter (a picker or 
editor that writes the
+    // cell) is skipped for the viewer, so a long formula or expression can 
still be read.
+    final boolean viewOnly = isViewOnly(colNr);
+    if (colinfo.isReadOnly() && colinfo.getSelectionAdapter() != null) {
       return;
     }
 
@@ -3299,7 +3400,7 @@ public class TableView extends Composite {
       disposeInlineEditor();
     }
 
-    if (colinfo.getSelectionAdapter() != null) {
+    if (!viewOnly && colinfo.getSelectionAdapter() != null) {
       Event e = new Event();
       e.widget = this;
       e.x = colNr;
@@ -3415,7 +3516,12 @@ public class TableView extends Composite {
         textWidget.addListener(SWT.KeyUp, lsKeyUp);
       }
     } else {
-      Text textWidget = new Text(editorParent, viewOnly ? SWT.READ_ONLY : 
SWT.NONE);
+      // A view-only password stays masked: it is opened to be looked at, not 
to be revealed.
+      int style = SWT.NONE;
+      if (viewOnly) {
+        style = SWT.READ_ONLY | (passwordField ? SWT.PASSWORD : SWT.NONE);
+      }
+      Text textWidget = new Text(editorParent, style);
       text = textWidget;
       textWidget.setText(content);
       // A view-only editor never changes anything, so it takes no modify/undo 
listeners.
@@ -3506,9 +3612,7 @@ public class TableView extends Composite {
     expandLabel.setToolTipText(
         BaseMessages.getString(
             PKG,
-            colinfo.isReadOnly()
-                ? "TableView.tooltip.ViewValue"
-                : "TableView.tooltip.ExpandValue"));
+            isViewOnly(colNr) ? "TableView.tooltip.ViewValue" : 
"TableView.tooltip.ExpandValue"));
 
     // The holder sits on top of the table cell, so without a background of 
its own the cell's own
     // text shows through around the icon. Take the editor's background so the 
two read as one field
@@ -3608,6 +3712,12 @@ public class TableView extends Composite {
     fieldChanged = false;
     ColumnInfo columnInfo = columns[colNr - 1];
 
+    // A read-only combo column still lets the user pick another value, so a 
read-only grid gets no
+    // combo at all. Its values are short and already shown in full.
+    if (readonly) {
+      return;
+    }
+
     if (columnInfo.isReadOnly() && columnInfo.getSelectionAdapter() != null) {
       return;
     }
@@ -3727,7 +3837,7 @@ public class TableView extends Composite {
 
     ColumnInfo columnInfo = columns[colNr - 1];
 
-    if (columnInfo.isReadOnly()) {
+    if (readonly || columnInfo.isReadOnly()) {
       return;
     }
 
@@ -4979,12 +5089,20 @@ public class TableView extends Composite {
         SWT.MenuDetect,
         event -> {
           Point pt = table.toControl(event.x, event.y);
-          boolean onHeader = pt.y >= 0 && pt.y < table.getHeaderHeight();
+          // Measure from the client area: GTK puts the header above y 0 (its 
client area starts
+          // at minus the header height), the other platforms below it. A menu 
opened from the
+          // keyboard is always the row menu.
+          Rectangle clientArea = table.getClientArea();
+          boolean onHeader =
+              event.detail != SWT.MENU_KEYBOARD
+                  && pt.y >= clientArea.y
+                  && pt.y < clientArea.y + table.getHeaderHeight();
           if (onHeader) {
             headerDataColumn[0] = findDataColumnAtX(pt.x);
             miHide.setEnabled(headerDataColumn[0] >= 0);
             table.setMenu(headerMenu);
           } else {
+            contextMenuPoint = event.detail == SWT.MENU_KEYBOARD ? null : pt;
             table.setMenu(rowMenu);
           }
         });
diff --git 
a/ui/src/main/java/org/apache/hop/ui/pipeline/debug/PipelineDebugDialog.java 
b/ui/src/main/java/org/apache/hop/ui/pipeline/debug/PipelineDebugDialog.java
index b155144893..9ce2e61338 100644
--- a/ui/src/main/java/org/apache/hop/ui/pipeline/debug/PipelineDebugDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/pipeline/debug/PipelineDebugDialog.java
@@ -191,21 +191,19 @@ public class PipelineDebugDialog extends Dialog {
     wTransforms.setLayoutData(fdTransform);
     wTransforms.table.setHeaderVisible(false);
 
-    // If someone clicks on a row, we want to refresh the right pane...
+    // If someone clicks on a row, we want to refresh the right pane. Listen 
for the selection
+    // only: a double-click on a row is left to the grid, which opens the cell.
     //
-    wTransforms.table.addSelectionListener(
-        new SelectionAdapter() {
-
-          @Override
-          public void widgetSelected(SelectionEvent e) {
-            // Before we show anything, make sure to save the content of the 
screen...
-            //
-            getTransformDebugMeta();
-
-            // Now show the information...
-            //
-            showTransformDebugInformation();
-          }
+    wTransforms.table.addListener(
+        SWT.Selection,
+        e -> {
+          // Before we show anything, make sure to save the content of the 
screen...
+          //
+          getTransformDebugMeta();
+
+          // Now show the information...
+          //
+          showTransformDebugInformation();
         });
 
     // If someone presses enter, launch the pipeline (this allows for 
"quick-preview")
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 181973ac92..f946ee498b 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
@@ -61,6 +61,7 @@ TableView.ErrorDialog.title=Unexpected error
 TableView.menu.ClearAll=Clear all
 TableView.menu.ClearSelection=Clear selection\tESC
 TableView.menu.CopyFieldToAllRows=Copy field value to all rows
+TableView.menu.CopyCellValue=Copy cell value
 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

Reply via email to