diff --git a/rcp/src/test/java/org/apache/hop/ui/core/dialog/ShowRowsDialogCellSelectionTest.java b/rcp/src/test/java/org/apache/hop/ui/core/dialog/ShowRowsDialogCellSelectionTest.java
new file mode 100644
index 0000000000..202bac6b54
--- /dev/null
+++ b/rcp/src/test/java/org/apache/hop/ui/core/dialog/ShowRowsDialogCellSelectionTest.java
@@ -0,0 +1,185 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Guards in-place cell selection in {@link ShowRowsDialog}: clicking a cell must drop a read-only,
+ * selectable text field on it that holds the cell's full value, so it can be copied even
+ * when the grid only shows a truncated, single-lined version.
+ *
+ *
This is the behaviour the transform/hop "output data" preview lost when it moved off {@link
+ * PreviewRowsDialog} (which has the same click-to-select editor) onto the simpler {@code
+ * ShowRowsDialog}. Without the editor, the click delivered below leaves no {@link Text} on the
+ * table and the assertions here fail.
+ */
+@Tag("uitest")
+class ShowRowsDialogCellSelectionTest extends SwtBotTestBase {
+
+ // Longer than the max preview cell length, so the grid truncates it but the editor must not.
+ private static final String LONG_VALUE =
+ "a very long value that the preview grid truncates for display but must stay selectable "
+ + "in full so it can be copied out of the cell without any characters being lost at all";
+
+ @Test
+ void clickingACellDropsAReadOnlyEditorHoldingTheFullValue() {
+ IRowMeta rowMeta = new RowMeta();
+ rowMeta.addValueMeta(new ValueMetaString("text"));
+ List rows = new ArrayList<>();
+ rows.add(new Object[] {LONG_VALUE});
+
+ withDialog(
+ parent ->
+ new ShowRowsDialog(parent, new Variables(), "Preview", "Output rows", rowMeta, rows)
+ .open(),
+ bot -> {
+ Table table = awaitTable(bot);
+ assertNotNull(table, "the ShowRowsDialog table should open");
+
+ clickFirstDataCell(table);
+
+ Text editor = awaitCellEditor(bot, table);
+ assertNotNull(
+ editor,
+ "clicking a cell should drop a selectable text field holding the value in place");
+ assertTrue(
+ (onUi(editor::getStyle) & SWT.READ_ONLY) != 0,
+ "the in-place cell editor must be read-only (a viewer, not an editor)");
+ assertEquals(
+ LONG_VALUE,
+ onUi(editor::getText),
+ "the editor must expose the full, untruncated cell value for copying");
+ });
+ }
+
+ /** Fires a left mouse-down at the centre of the first data cell (row 0, first value column). */
+ private void clickFirstDataCell(Table table) {
+ onUi(
+ () -> {
+ TableItem item = table.getItem(0);
+ Rectangle bounds = item.getBounds(1); // column 0 is the row number
+ Event event = new Event();
+ event.button = 1;
+ event.x = bounds.x + bounds.width / 2;
+ event.y = bounds.y + bounds.height / 2;
+ table.notifyListeners(SWT.MouseDown, event);
+ return null;
+ });
+ }
+
+ /** Polls for the ShowRowsDialog's table across the open shells. */
+ private Table awaitTable(SWTBot bot) {
+ for (int attempt = 0; attempt < 40; attempt++) {
+ Table found = onUi(ShowRowsDialogCellSelectionTest::findTableOnUi);
+ if (found != null) {
+ return found;
+ }
+ bot.sleep(50);
+ }
+ return null;
+ }
+
+ /** Polls for the read-only editor the click drops directly on the table. */
+ private Text awaitCellEditor(SWTBot bot, Table table) {
+ for (int attempt = 0; attempt < 40; attempt++) {
+ Text found = onUi(() -> findDirectTextChild(table));
+ if (found != null) {
+ return found;
+ }
+ bot.sleep(50);
+ }
+ return null;
+ }
+
+ private static Table findTableOnUi() {
+ for (Shell openShell : display.getShells()) {
+ Table found = findTable(openShell);
+ if (found != null) {
+ return found;
+ }
+ }
+ return null;
+ }
+
+ private static Table findTable(org.eclipse.swt.widgets.Composite parent) {
+ for (Control child : parent.getChildren()) {
+ if (child instanceof Table table) {
+ return table;
+ }
+ if (child instanceof org.eclipse.swt.widgets.Composite composite) {
+ Table found = findTable(composite);
+ if (found != null) {
+ return found;
+ }
+ }
+ }
+ return null;
+ }
+
+ /** The cell editor is a {@link Text} parented straight onto the table (via a TableEditor). */
+ private static Text findDirectTextChild(Table table) {
+ for (Control child : table.getChildren()) {
+ if (child instanceof Text text && !text.isDisposed()) {
+ return text;
+ }
+ }
+ return null;
+ }
+
+ /** Runs {@code supplier} on the UI thread and hands its result back to the SWTBot worker. */
+ private static T onUi(Supplier supplier) {
+ AtomicReference result = new AtomicReference<>();
+ AtomicReference 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/ShowRowsDialog.java b/ui/src/main/java/org/apache/hop/ui/core/dialog/ShowRowsDialog.java
index bbd246ed11..b6dab857de 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/ShowRowsDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ShowRowsDialog.java
@@ -34,6 +34,10 @@
import org.apache.hop.ui.core.widget.ColumnInfo;
import org.apache.hop.ui.core.widget.TableView;
import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.TableEditor;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FillLayout;
import org.eclipse.swt.layout.FormAttachment;
import org.eclipse.swt.layout.FormData;
import org.eclipse.swt.layout.FormLayout;
@@ -41,6 +45,7 @@
import org.eclipse.swt.widgets.MessageBox;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
/**
* Read-only dialog that shows a static set of rows with a custom title and header message.
@@ -60,6 +65,11 @@ public final class ShowRowsDialog {
Const.toBoolean(
HopConfig.readStringVariable(Const.HOP_BINARY_FIELDS_AVOID_HEX_PREVIEW, "false"));
+ /** Caps for the floating full-value box: it grows to fit the value, then scrolls beyond these. */
+ private static final int MAX_OVERLAY_WIDTH = 600;
+
+ private static final int MAX_OVERLAY_HEIGHT = 400;
+
private final Shell parentShell;
private final IVariables variables;
private final String title;
@@ -71,6 +81,14 @@ public final class ShowRowsDialog {
private TableView tableView;
private int lineNr;
+ /** Read-only text field overlaid on the clicked cell so its value can be selected in place. */
+ private TableEditor cellEditor;
+
+ private Text cellEditorText;
+
+ /** Lightweight floating box showing the full value of an overflowing cell (Ctrl+Space style). */
+ private Shell valueOverlay;
+
public ShowRowsDialog(
Shell parent,
IVariables variables,
@@ -126,6 +144,7 @@ public void open() {
tableView = buildTableView(margin, messageLabel);
populateRows();
+ setupCellSelection();
BaseDialog.defaultShellHandling(shell, c -> close(), c -> close());
}
@@ -152,7 +171,10 @@ private TableView buildTableView(int margin, Label messageLabel) {
null,
PropsUi.getInstance());
view.setShowingBlueNullValues(true);
- view.setSortable(true);
+ // Rows are kept in load order so a cell's visual position maps straight back to the buffer that
+ // holds its full value (see getFullCellString). Sorting rebuilds the table items from their
+ // truncated display text, breaking that mapping, so it is disabled here.
+ view.setSortable(false);
FormData fdTable = new FormData();
fdTable.left = new FormAttachment(0, 0);
@@ -230,7 +252,248 @@ private void close() {
if (shell == null || shell.isDisposed()) {
return;
}
+ if (cellEditor != null) {
+ cellEditor.dispose();
+ }
PropsUi.getInstance().setScreen(new WindowProperty(shell));
shell.dispose();
}
+
+ /**
+ * Cells only show a truncated, single-lined value for performance (see {@link
+ * TableView#formatCellValueForDisplay}). Clicking a cell drops a read-only text field on it (like
+ * the inline editor of an editable grid) so its full value can be selected and copied in place;
+ * double-clicking a cell shows its full, original content in a floating box (handy for long
+ * strings, JSON and multi-line values).
+ */
+ private void setupCellSelection() {
+ cellEditor = new TableEditor(tableView.table);
+ cellEditor.grabHorizontal = true;
+ cellEditor.horizontalAlignment = SWT.LEFT;
+ tableView.table.addListener(
+ SWT.MouseDown,
+ event -> {
+ // Leave Shift/Ctrl clicks to the table so row range/toggle selection keeps working.
+ if (event.button == 1 && (event.stateMask & (SWT.SHIFT | SWT.MOD1)) == 0) {
+ openCellEditor(new Point(event.x, event.y));
+ }
+ });
+ tableView.table.addListener(
+ SWT.MouseDoubleClick, event -> showFullCellValue(new Point(event.x, event.y)));
+ }
+
+ private void showFullCellValue(Point point) {
+ CellRef ref = cellAt(point);
+ if (ref != null) {
+ expandCell(ref.bounds, ref.rowIndex, ref.columnIndex);
+ }
+ }
+
+ /**
+ * Single-click handler: drop a read-only text field on the clicked cell — the same cell-fitting
+ * effect as an editable grid's inline editor — holding the full, untruncated value so it can be
+ * selected and copied in place. Double-clicking the field expands it to the full-value box.
+ */
+ private void openCellEditor(Point point) {
+ CellRef ref = cellAt(point);
+ if (ref == null) {
+ return;
+ }
+ String full = getFullCellString(ref.rowIndex, ref.columnIndex - 1);
+ if (full == null) {
+ return;
+ }
+ TableItem item = tableView.table.getItem(ref.rowIndex);
+
+ if (cellEditorText != null && !cellEditorText.isDisposed()) {
+ cellEditorText.dispose();
+ }
+
+ final Text field = new Text(tableView.table, SWT.SINGLE | SWT.READ_ONLY);
+ PropsUi.setLook(field);
+ field.setText(full);
+ cellEditorText = field;
+ final long openedAt = System.currentTimeMillis();
+
+ // Escape or losing focus removes the field again.
+ field.addListener(
+ SWT.KeyDown,
+ e -> {
+ if (e.keyCode == SWT.ESC) {
+ field.dispose();
+ }
+ });
+ field.addListener(SWT.FocusOut, e -> field.dispose());
+
+ // Double-clicking the field expands to the full-value box. The field is created on the first
+ // click, so on some platforms the second click of a double-click lands on this fresh field as a
+ // plain MouseDown; treat a click within the OS double-click time of it opening as that second
+ // click too. Coordinates are captured so the box anchors to the same cell.
+ final Rectangle cellBounds = ref.bounds;
+ final int rowIndex = ref.rowIndex;
+ final int columnIndex = ref.columnIndex;
+ field.addListener(SWT.MouseDoubleClick, e -> expandCell(cellBounds, rowIndex, columnIndex));
+ field.addListener(
+ SWT.MouseDown,
+ e -> {
+ if (System.currentTimeMillis() - openedAt
+ <= tableView.getDisplay().getDoubleClickTime()) {
+ expandCell(cellBounds, rowIndex, columnIndex);
+ }
+ });
+
+ cellEditor.setEditor(field, item, columnIndex);
+ field.setFocus();
+ field.selectAll();
+ }
+
+ /** Expand the given cell's full value into the floating, selectable value box. */
+ private void expandCell(Rectangle cellBounds, int rowIndex, int columnIndex) {
+ if (cellEditorText != null && !cellEditorText.isDisposed()) {
+ cellEditorText.dispose();
+ }
+ // A double-click fires both a MouseDown (caught within the double-click window) and a
+ // MouseDoubleClick; ignore the second while the box for this click is still up.
+ if (valueOverlay != null && !valueOverlay.isDisposed()) {
+ return;
+ }
+ String full = getFullCellString(rowIndex, columnIndex - 1);
+ if (full != null) {
+ showValueOverlay(cellBounds, full);
+ }
+ }
+
+ /**
+ * Resolve the data cell under a table-relative point, or null when the point isn't over a data
+ * cell (the row-number column, empty space, or a row/column outside the backing buffer).
+ */
+ private CellRef cellAt(Point point) {
+ if (tableView == null || tableView.isDisposed() || rows == null || rowMeta == null) {
+ return null;
+ }
+ TableItem item = tableView.table.getItem(point);
+ if (item == null) {
+ return null;
+ }
+ int rowIndex = tableView.table.indexOf(item);
+ if (rowIndex < 0 || rowIndex >= rows.size()) {
+ return null;
+ }
+ // Column 0 is the row-number column; data columns start at 1. Find the one under the pointer.
+ for (int i = 1; i < tableView.table.getColumnCount(); i++) {
+ Rectangle b = item.getBounds(i);
+ if (b.contains(point)) {
+ return i - 1 < rowMeta.size() ? new CellRef(rowIndex, i, b) : null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * A located data cell: its row index into the buffer, its 1-based table column, and its bounds.
+ */
+ private static final class CellRef {
+ private final int rowIndex;
+ private final int columnIndex;
+ private final Rectangle bounds;
+
+ private CellRef(int rowIndex, int columnIndex, Rectangle bounds) {
+ this.rowIndex = rowIndex;
+ this.columnIndex = columnIndex;
+ this.bounds = bounds;
+ }
+ }
+
+ /**
+ * Show the full cell value in a lightweight, non-modal multi-line text box anchored to the cell —
+ * the same floating-shell idea as the Ctrl+Space variable helper. Dismisses on Escape or when it
+ * loses focus.
+ */
+ private void showValueOverlay(Rectangle cellBounds, String value) {
+ if (valueOverlay != null && !valueOverlay.isDisposed()) {
+ valueOverlay.dispose();
+ }
+
+ Point location = tableView.table.toDisplay(cellBounds.x, cellBounds.y);
+
+ // A resizable (but title-less) floating shell: light like the variable helper, yet the user can
+ // drag its edges to make it bigger for a long value.
+ final Shell overlay = new Shell(shell, SWT.RESIZE);
+ overlay.setLayout(new FillLayout());
+
+ final Text text =
+ new Text(overlay, SWT.MULTI | SWT.WRAP | SWT.V_SCROLL | SWT.READ_ONLY | SWT.BORDER);
+ PropsUi.setLook(text);
+ text.setText(value);
+
+ // Size the box to its content. Width comes from the value's natural (unwrapped) width, at least
+ // the cell width and capped so it never sprawls across the screen; height is then measured
+ // after
+ // the value wraps to that width (minus the border + scrollbar gutter), so a value that spans
+ // several lines gets a taller box instead of a scrollbar — we only scroll past the height cap.
+ Point natural = text.computeSize(SWT.DEFAULT, SWT.DEFAULT);
+ int contentWidth = Math.min(natural.x + 20, MAX_OVERLAY_WIDTH);
+ int width = Math.max(cellBounds.width, contentWidth);
+ int wrapWidth = Math.max(50, width - 24);
+ Point wrapped = text.computeSize(wrapWidth, SWT.DEFAULT);
+ int contentHeight = Math.min(wrapped.y + 8, MAX_OVERLAY_HEIGHT);
+ int height = Math.max(cellBounds.height + 4, contentHeight);
+ overlay.setSize(width, height);
+ overlay.setLocation(location.x, location.y);
+
+ // Dismiss on Escape.
+ text.addListener(
+ SWT.KeyDown,
+ e -> {
+ if (e.keyCode == SWT.ESC) {
+ overlay.dispose();
+ }
+ });
+
+ overlay.open();
+ valueOverlay = overlay;
+
+ // Grab focus after the current (double-click) event settles, so a trailing table focus event
+ // can't immediately close the box. Pre-select the whole value so it's ready to copy. Only after
+ // focus is settled inside the box do we arm click-away dismissal — via shell deactivation
+ // rather
+ // than a text focus-out, so grabbing a resize edge (which keeps the shell active) doesn't close
+ // it.
+ overlay
+ .getDisplay()
+ .asyncExec(
+ () -> {
+ if (text.isDisposed()) {
+ return;
+ }
+ text.setFocus();
+ text.selectAll();
+ overlay.addListener(
+ SWT.Deactivate,
+ e -> {
+ if (!overlay.isDisposed()) {
+ overlay.dispose();
+ }
+ });
+ });
+ }
+
+ /** Convert the raw buffer value at (rowIndex, column) to its full string form, no truncation. */
+ private String getFullCellString(int rowIndex, int column) {
+ Object[] row = rows.get(rowIndex);
+ IValueMeta valueMeta = rowMeta.getValueMeta(column);
+ try {
+ if (valueMeta.isBinary()) {
+ byte[] bytes = valueMeta.getBinary(row[column]);
+ if (bytes == null) {
+ return null;
+ }
+ return AVOID_BINARY_IN_HEX ? valueMeta.getString(bytes) : Hex.encodeHexString(bytes);
+ }
+ return valueMeta.getString(row[column]);
+ } catch (HopValueException e) {
+ new LogChannel(PKG).logError("Unable to format cell value", e);
+ return null;
+ }
+ }
}