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