From d85ca1f203dc21b76686f121c05ab83f5c3407a0 Mon Sep 17 00:00:00 2001 From: Cameron Weick Date: Mon, 20 Apr 2026 12:59:57 +1000 Subject: [PATCH 1/4] [grid] Polish frozen first column support Builds on the basic fixed column scaffolding from #645 to make a frozen first column behave correctly across the rest of Grid: - getColumn(Point) resolves hits inside the fixed-column overlay to the frozen column on top instead of the scrolled column underneath. This automatically fixes getCell, overColumnHeader, overColumnFooter, and the mouse handlers that funnel through it. - getColumnHeaderXPosition and getOrigin are now fixed-aware, so GridEditor.computeEditorBounds and GridItem.getBoundsCorrected place cell editors on the frozen column instead of off-screen. - The fixed cell-painting pass now clips to the overlay rectangle so renderers cannot bleed into the scrolled area. - paintFooter got a fixed-aware re-pass so the frozen column footer stays put on horizontal scroll, mirroring the existing header pass. - The focus rectangle starts to the right of the frozen overlay so its left edge stays visible. - The header fixed pass detects when a frozen column shares a column group with scrollable members; it logs a one-time warning to stderr and degrades gracefully by drawing only the column header (no group band) in the overlay. - Updated GridColumn#setFixed Javadoc to document the supported case (single frozen first column) and the column-group restriction. - Replaced the stale "TODO: column freezing" note with a pointer to setFixed and the new snippet. Adds GridFrozenFirstColumnSnippet (plain frozen column with editor + group-spanning case) and GridFixedColumn_Test (setFixed, frozen-aware getOrigin, hit-testing under scroll, one-shot group-spanning warning). Fixes #695 Signed-off-by: Cameron Weick --- .../grid/GridFrozenFirstColumnSnippet.java | 163 +++++++++++++ .../widgets/grid/GridFixedColumn_Test.java | 184 +++++++++++++++ .../org/eclipse/nebula/widgets/grid/Grid.java | 222 ++++++++++++++++-- .../nebula/widgets/grid/GridColumn.java | 20 +- 4 files changed, 569 insertions(+), 20 deletions(-) create mode 100644 examples/org.eclipse.nebula.snippets/src/org/eclipse/nebula/snippets/grid/GridFrozenFirstColumnSnippet.java create mode 100644 widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java diff --git a/examples/org.eclipse.nebula.snippets/src/org/eclipse/nebula/snippets/grid/GridFrozenFirstColumnSnippet.java b/examples/org.eclipse.nebula.snippets/src/org/eclipse/nebula/snippets/grid/GridFrozenFirstColumnSnippet.java new file mode 100644 index 0000000000..85c4c35100 --- /dev/null +++ b/examples/org.eclipse.nebula.snippets/src/org/eclipse/nebula/snippets/grid/GridFrozenFirstColumnSnippet.java @@ -0,0 +1,163 @@ +/******************************************************************************* + * Copyright (c) 2026 Eclipse Nebula contributors. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ +package org.eclipse.nebula.snippets.grid; + +import org.eclipse.nebula.widgets.grid.Grid; +import org.eclipse.nebula.widgets.grid.GridColumn; +import org.eclipse.nebula.widgets.grid.GridColumnGroup; +import org.eclipse.nebula.widgets.grid.GridEditor; +import org.eclipse.nebula.widgets.grid.GridItem; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionAdapter; +import org.eclipse.swt.events.SelectionEvent; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * Demonstrates a frozen first column in {@link Grid}. + * + *

Two scenarios are shown side by side:

+ * + */ +public class GridFrozenFirstColumnSnippet { + + private static final int COLUMN_COUNT = 8; + private static final int ROW_COUNT = 30; + + public static void main(String[] args) { + Display display = new Display(); + Shell shell = new Shell(display); + shell.setText("Grid - Frozen First Column"); + shell.setLayout(new GridLayout(1, false)); + shell.setSize(700, 500); + + createSimpleSection(shell); + createGroupSection(shell); + + shell.open(); + while (!shell.isDisposed()) { + if (!display.readAndDispatch()) { + display.sleep(); + } + } + display.dispose(); + } + + private static void createSimpleSection(Composite parent) { + Composite section = new Composite(parent, SWT.NONE); + section.setLayout(new GridLayout(1, false)); + section.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + Grid grid = new Grid(section, SWT.BORDER | SWT.V_SCROLL | SWT.H_SCROLL); + grid.setHeaderVisible(true); + grid.setLinesVisible(true); + grid.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + GridColumn frozen = new GridColumn(grid, SWT.NONE); + frozen.setText("Name (frozen)"); + frozen.setWidth(160); + frozen.setFixed(true); + + for (int c = 1; c < COLUMN_COUNT; c++) { + GridColumn column = new GridColumn(grid, SWT.NONE); + column.setText("Column " + c); + column.setWidth(120); + } + + for (int r = 0; r < ROW_COUNT; r++) { + GridItem item = new GridItem(grid, SWT.NONE); + item.setText(0, "Row " + (r + 1)); + for (int c = 1; c < COLUMN_COUNT; c++) { + item.setText(c, "r" + (r + 1) + "c" + c); + } + } + + attachFirstColumnEditor(grid); + } + + private static void createGroupSection(Composite parent) { + Composite section = new Composite(parent, SWT.NONE); + section.setLayout(new GridLayout(1, false)); + section.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + Grid grid = new Grid(section, SWT.BORDER | SWT.V_SCROLL | SWT.H_SCROLL); + grid.setHeaderVisible(true); + grid.setLinesVisible(true); + grid.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + GridColumnGroup group = new GridColumnGroup(grid, SWT.NONE); + group.setText("Spans frozen + scrolled (unsupported, warns once)"); + + GridColumn frozen = new GridColumn(group, SWT.NONE); + frozen.setText("Name (frozen)"); + frozen.setWidth(160); + frozen.setFixed(true); + + GridColumn inGroup = new GridColumn(group, SWT.NONE); + inGroup.setText("Group col"); + inGroup.setWidth(120); + + for (int c = 2; c < COLUMN_COUNT; c++) { + GridColumn column = new GridColumn(grid, SWT.NONE); + column.setText("Column " + c); + column.setWidth(120); + } + + for (int r = 0; r < ROW_COUNT; r++) { + GridItem item = new GridItem(grid, SWT.NONE); + for (int c = 0; c < COLUMN_COUNT; c++) { + item.setText(c, "r" + (r + 1) + "c" + c); + } + } + } + + private static void attachFirstColumnEditor(Grid grid) { + final GridEditor editor = new GridEditor(grid); + editor.grabHorizontal = true; + grid.addSelectionListener(new SelectionAdapter() { + @Override + public void widgetSelected(SelectionEvent e) { + Control old = editor.getEditor(); + if (old != null) { + old.dispose(); + } + GridItem[] selection = grid.getSelection(); + if (selection.length == 0) { + return; + } + GridItem item = selection[0]; + Text text = new Text(grid, SWT.NONE); + text.setText(item.getText(0)); + text.selectAll(); + text.setFocus(); + text.addListener(SWT.FocusOut, ev -> { + item.setText(0, text.getText()); + text.dispose(); + }); + editor.setEditor(text, item, 0); + } + }); + } +} diff --git a/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java b/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java new file mode 100644 index 0000000000..d9563b87b8 --- /dev/null +++ b/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java @@ -0,0 +1,184 @@ +/******************************************************************************* + * Copyright (c) 2026 Eclipse Nebula contributors. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + ******************************************************************************/ +package org.eclipse.nebula.widgets.grid; + +import static org.eclipse.nebula.widgets.grid.GridTestUtil.createGridColumns; +import static org.eclipse.nebula.widgets.grid.GridTestUtil.createGridItems; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; + +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.ScrollBar; +import org.eclipse.swt.widgets.Shell; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + + +/** + * Tests for the frozen-first-column behavior of {@link Grid}/{@link GridColumn}. + */ +@SuppressWarnings({ "restriction", "deprecation" }) +public class GridFixedColumn_Test { + + private Display display; + private Shell shell; + private Grid grid; + private ScrollBar horizontalBar; + + @Before + public void setUp() { + display = Display.getDefault(); + shell = new Shell( display ); + grid = new Grid( shell, SWT.H_SCROLL | SWT.V_SCROLL ); + grid.setSize( 200, 200 ); + horizontalBar = grid.getHorizontalBar(); + shell.pack(); + shell.open(); + while( display.readAndDispatch() ) { + // drain pending events so paint requests are issued + } + } + + @After + public void tearDown() { + if( shell != null && !shell.isDisposed() ) { + shell.dispose(); + } + } + + @Test + public void testSetFixed_TogglesFlag() { + GridColumn column = new GridColumn( grid, SWT.NONE ); + + assertFalse( column.isFixed() ); + column.setFixed( true ); + assertTrue( column.isFixed() ); + column.setFixed( false ); + assertFalse( column.isFixed() ); + } + + @Test + public void testGetOrigin_FrozenColumn_PinnedAcrossScroll() { + GridColumn[] columns = createGridColumns( grid, 10, SWT.NONE ); + GridItem[] items = createGridItems( grid, 20, 0 ); + columns[ 0 ].setFixed( true ); + + horizontalBar.setSelection( 150 ); + + Point origin = grid.getOrigin( columns[ 0 ], items[ 0 ] ); + + assertEquals( "Frozen column origin should ignore horizontal scroll", 0, origin.x ); + } + + @Test + public void testGetOrigin_NonFrozenColumn_StillScrolls() { + GridColumn[] columns = createGridColumns( grid, 10, SWT.NONE ); + GridItem[] items = createGridItems( grid, 20, 0 ); + columns[ 0 ].setFixed( true ); + + Point unscrolled = grid.getOrigin( columns[ 3 ], items[ 0 ] ); + horizontalBar.setSelection( 150 ); + Point scrolled = grid.getOrigin( columns[ 3 ], items[ 0 ] ); + + assertEquals( "Non-frozen column origin must shift by scroll delta", + unscrolled.x - 150, scrolled.x ); + } + + @Test + public void testGetColumn_FrozenOverlayWinsHitTesting() { + GridColumn[] columns = createGridColumns( grid, 10, SWT.NONE ); + createGridItems( grid, 20, 0 ); + GridColumn frozen = columns[ 0 ]; + frozen.setFixed( true ); + + // Force the overlay to be active by scrolling past the frozen offset. + horizontalBar.setSelection( 150 ); + + // The frozen column has width 20 (col_0 -> 20 * (0+1)), so a hit at x=5 + // falls inside the overlay region. + GridColumn hit = grid.getColumn( new Point( 5, 5 ) ); + assertSame( "Hit inside frozen overlay should resolve to the frozen column", + frozen, hit ); + } + + @Test + public void testGetColumn_OutsideFrozenOverlay_ResolvesScrolledColumn() { + GridColumn[] columns = createGridColumns( grid, 10, SWT.NONE ); + createGridItems( grid, 20, 0 ); + columns[ 0 ].setFixed( true ); + + horizontalBar.setSelection( 150 ); + + GridColumn hit = grid.getColumn( new Point( 100, 5 ) ); + assertNotNull( hit ); + assertFalse( "Hit outside the frozen overlay must resolve to a scrolled column", + hit == columns[ 0 ] ); + } + + @Test + public void testGroupSpanning_AcrossFreezeBoundary_WarnsOnceAndDegrades() { + grid.setHeaderVisible( true ); + + GridColumnGroup group = new GridColumnGroup( grid, SWT.NONE ); + group.setText( "Mixed" ); + GridColumn frozen = new GridColumn( group, SWT.NONE ); + frozen.setText( "frozen" ); + frozen.setWidth( 50 ); + frozen.setFixed( true ); + GridColumn scrolled = new GridColumn( group, SWT.NONE ); + scrolled.setText( "scrolled" ); + scrolled.setWidth( 50 ); + // intentionally not setFixed(true) + + for( int i = 0; i < 8; i++ ) { + GridColumn other = new GridColumn( grid, SWT.NONE ); + other.setText( "c" + i ); + other.setWidth( 80 ); + } + createGridItems( grid, 5, 0 ); + + horizontalBar.setSelection( 150 ); + + PrintStream originalErr = System.err; + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + System.setErr( new PrintStream( captured ) ); + try { + grid.redraw(); + grid.update(); + while( display.readAndDispatch() ) { + // drain + } + // Trigger a second paint to ensure the warning only fires once. + grid.redraw(); + grid.update(); + while( display.readAndDispatch() ) { + // drain + } + } finally { + System.setErr( originalErr ); + } + + String stderr = captured.toString(); + int firstOccurrence = stderr.indexOf( "[nebula.grid]" ); + int lastOccurrence = stderr.lastIndexOf( "[nebula.grid]" ); + assertEquals( "Warning should be emitted exactly once across multiple paints", + firstOccurrence, lastOccurrence ); + } +} diff --git a/widgets/grid/org.eclipse.nebula.widgets.grid/src/org/eclipse/nebula/widgets/grid/Grid.java b/widgets/grid/org.eclipse.nebula.widgets.grid/src/org/eclipse/nebula/widgets/grid/Grid.java index b368f7993e..16e61b03bb 100644 --- a/widgets/grid/org.eclipse.nebula.widgets.grid/src/org/eclipse/nebula/widgets/grid/Grid.java +++ b/widgets/grid/org.eclipse.nebula.widgets.grid/src/org/eclipse/nebula/widgets/grid/Grid.java @@ -29,8 +29,10 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Set; import java.util.Vector; import java.util.function.Consumer; import java.util.function.ToIntFunction; @@ -117,7 +119,8 @@ public class Grid extends Canvas { // TODO: need to alter how column drag selection works to allow selection of // spanned cells // TODO: JAVADOC! - // TODO: column freezing + // Column freezing: the supported case is a single frozen first column + // via GridColumn#setFixed(true). See GridFrozenFirstColumnSnippet. // TODO: Performance - need to cache top index @@ -422,6 +425,12 @@ public void clearItems() { */ private int footerHeight = 0; + /** + * Tracks column groups already warned about spanning the fixed/scrollable + * boundary, so we only emit the warning once per group. + */ + private final Set warnedFixedGroups = new HashSet<>(); + /** * True if mouse is hover on a column boundary and can resize the column. */ @@ -1277,19 +1286,41 @@ private GridColumn getColumn(GridItem item, final Point point) { x2 += rowHeaderWidth; } + // When the fixed-column overlay is active, hits inside the overlay + // rectangle resolve to the fixed column on top, not the scrolled + // column underneath. + if (isFixedOverlayActive()) { + int fx = rowHeaderVisible ? rowHeaderWidth : 0; + for (final GridColumn column : displayOrderedColumns) { + if (!column.isVisible()) { + continue; + } + if (!column.isFixed()) { + break; + } + if (point.x >= fx && point.x < fx + column.getWidth()) { + overThis = column; + break; + } + fx += column.getWidth(); + } + } + x2 -= getHScrollSelectionInPixels(); - for (final GridColumn column : displayOrderedColumns) { - if (!column.isVisible()) { - continue; - } + if (overThis == null) { + for (final GridColumn column : displayOrderedColumns) { + if (!column.isVisible()) { + continue; + } - if (point.x >= x2 && point.x < x2 + column.getWidth()) { - overThis = column; - break; - } + if (point.x >= x2 && point.x < x2 + column.getWidth()) { + overThis = column; + break; + } - x2 += column.getWidth(); + x2 += column.getWidth(); + } } if (overThis == null) { @@ -4555,7 +4586,8 @@ private int computeItemHeight(final GridItem item) { /** * Returns the x position of the given column. Takes into account scroll - * position. + * position. Fixed columns always return their unscrolled on-screen x so + * they sit on top of the scrollable area when overlaid. * * @param column given column * @return x position @@ -4567,7 +4599,11 @@ private int getColumnHeaderXPosition(final GridColumn column) { int x = 0; - x -= getHScrollSelectionInPixels(); + // Fixed columns are always painted at their unscrolled position so the + // frozen overlay aligns with mouse hits, editors, and drag indicators. + if (!column.isFixed()) { + x -= getHScrollSelectionInPixels(); + } if (rowHeaderVisible) { x += rowHeaderWidth; @@ -4582,12 +4618,50 @@ private int getColumnHeaderXPosition(final GridColumn column) { break; } + // When laying out a fixed column, only sum widths of preceding + // fixed columns; preceding scrollable columns sit behind it. + if (column.isFixed() && !column2.isFixed()) { + continue; + } + x += column2.getWidth(); } return x; } + /** + * Returns the total on-screen width occupied by the leading fixed columns + * (excluding the row header). Used by hit testing and overlay clipping. + * + * @return width in pixels of the fixed-column overlay region; 0 if none + */ + private int getFixedColumnsWidth() { + int width = 0; + for (final GridColumn column : displayOrderedColumns) { + if (!column.isVisible()) { + continue; + } + if (!column.isFixed()) { + break; + } + width += column.getWidth(); + } + return width; + } + + /** + * Returns whether the fixed-column overlay is currently visible (i.e. the + * grid has at least one fixed column AND the user has scrolled past where + * the unscrolled fixed columns would naturally sit). + * + * @return true if the overlay is being painted on top of scrolled content + */ + private boolean isFixedOverlayActive() { + final FixedGridColumns fixed = getFixedGridColumns(); + return fixed.hasColumns() && getHScrollSelectionInPixels() > fixed.offset(); + } + /** * Returns the hscroll selection in pixels. This method abstracts away the * differences between column by column scrolling and pixel based scrolling. @@ -5343,8 +5417,23 @@ private void onPaint(final PaintEvent event) { } final FixedGridColumns fixed = getFixedGridColumns(); if (fixed.hasColumns() && hscroll > fixed.offset()) { - paintRows(fixed.columns(), true, firstItemToDraw, visibleRows, 0, cellSpanManager, gc, originalClipping, y, - clientArea, firstVisibleIndex, insertMark, extraFill); + // Clip the entire fixed-overlay pass to the on-screen rectangle of + // the frozen columns so cell rendering can never bleed into the + // scrolled area, even if a renderer paints outside its bounds. + final int fixedX = rowHeaderVisible ? rowHeaderWidth : 0; + final int fixedTop = columnHeadersVisible ? headerHeight : 0; + final int fixedBottom = columnFootersVisible ? footerHeight : 0; + final Rectangle fixedRect = new Rectangle(fixedX, fixedTop, getFixedColumnsWidth(), + Math.max(0, clientArea.height - fixedTop - fixedBottom)); + final Rectangle fixedClipping = originalClipping.intersection(fixedRect); + final Rectangle priorClipping = gc.getClipping(); + gc.setClipping(fixedClipping); + try { + paintRows(fixed.columns(), true, firstItemToDraw, visibleRows, 0, cellSpanManager, gc, fixedClipping, y, + clientArea, firstVisibleIndex, insertMark, extraFill); + } finally { + gc.setClipping(priorClipping); + } } // draw insertion mark @@ -5555,6 +5644,12 @@ private void paintRows(final List cols, final boolean fixed, final i if (rowHeaderVisible) { focusX = rowHeaderWidth; } + // Start the focus rectangle to the right of the + // fixed-column overlay so its left edge stays + // visible instead of being hidden underneath. + if (!fixed && isFixedOverlayActive()) { + focusX += getFixedColumnsWidth(); + } focusRenderer.setBounds(focusX, focusY - 1, clientArea.width - focusX - 1, item.getHeight() + 1); focusRenderer.paint(gc, item); @@ -5768,12 +5863,80 @@ private void paintHeader(final GC gc, final int extraFill) { if (!column.isVisible() || !column.isFixed()) { continue; } - previousPaintedGroup = paintColumnHeaderWithGroup(column, x, gc, previousPaintedGroup, extraFill); + if (groupSpansFreezeBoundary(column)) { + // Frozen column shares a column group with one or more + // scrollable siblings. Painting the group header would + // produce a wide overlay floating above the scrolled + // content; instead paint just the column header. + warnFixedGroupSpanningOnce(column.getColumnGroup()); + paintFixedColumnHeaderWithoutGroup(column, x, gc, extraFill); + previousPaintedGroup = null; + } else { + previousPaintedGroup = paintColumnHeaderWithGroup(column, x, gc, previousPaintedGroup, extraFill); + } x += column.getWidth(); } } } + /** + * Returns true when the given fixed column belongs to a {@link GridColumnGroup} + * that also contains at least one non-fixed (scrollable) column. Such a + * configuration is unsupported for the frozen-overlay rendering. + */ + private boolean groupSpansFreezeBoundary(final GridColumn column) { + final GridColumnGroup group = column.getColumnGroup(); + if (group == null) { + return false; + } + for (final GridColumn member : group.getColumns()) { + if (!member.isFixed()) { + return true; + } + } + return false; + } + + /** + * Logs a one-time warning to stderr about a column group that spans the + * frozen/scrollable boundary, so developers notice without spamming the + * paint cycle. + */ + private void warnFixedGroupSpanningOnce(final GridColumnGroup group) { + if (group == null || !warnedFixedGroups.add(group)) { + return; + } + System.err.println("[nebula.grid] Fixed column belongs to column group '" + group.getText() + + "' that also contains scrollable columns; the group header is omitted from the frozen overlay. " + + "Place the fixed column outside any multi-column group."); + } + + /** + * Paints just the column-area portion of a fixed column header (skipping the + * group header band) at the given x. + */ + private void paintFixedColumnHeaderWithoutGroup(final GridColumn column, final int x, final GC gc, + final int extraFill) { + final int width = column.getWidth(extraFill); + final int y = column.getColumnGroup() != null ? groupHeaderHeight : 0; + final int height = column.getColumnGroup() != null ? headerHeight - groupHeaderHeight : headerHeight; + + final GridHeaderRenderer renderer = column.getHeaderRenderer(); + if (pushingColumn) { + renderer.setHover(columnBeingPushed == column && pushingAndHovering); + } else { + renderer.setHover(hoveringColumnHeader == column); + } + renderer.setHoverDetail(hoveringDetail); + renderer.setBounds(x, y, width, height); + if (cellSelectionEnabled) { + renderer.setSelected(selectedColumns.contains(column)); + } + if (x + width >= 0) { + renderer.paint(gc, column); + } + } + int getExtraFill() { return getExtraFill(getSize()); } @@ -5920,6 +6083,23 @@ private void paintFooter(final GC gc) { bottomLeftRenderer.paint(gc, this); x += rowHeaderWidth; } + + // Fixed-column footer overlay. Paint after the scrolled footer pass so + // the frozen footer cells sit on top when the user has scrolled past + // where they would otherwise sit. + final FixedGridColumns fixed = getFixedGridColumns(); + if (fixed.hasColumns() && getHScrollSelectionInPixels() > fixed.offset()) { + int fx = rowHeaderVisible ? rowHeaderWidth : 0; + final int fy = getClientArea().height - footerHeight; + for (final GridColumn column : fixed.columns()) { + if (!column.isVisible()) { + continue; + } + column.getFooterRenderer().setBounds(fx, fy, column.getWidth(), footerHeight); + column.getFooterRenderer().paint(gc, column); + fx += column.getWidth(); + } + } } /** @@ -7551,7 +7731,11 @@ Point getOrigin(final GridColumn column, final GridItem item) { x += rowHeaderWidth; } - x -= getHScrollSelectionInPixels(); + // Fixed columns are pinned to their unscrolled position so cell + // editors and bounds queries align with the painted overlay. + if (column == null || !column.isFixed()) { + x -= getHScrollSelectionInPixels(); + } for (final GridColumn colIter : displayOrderedColumns) { @@ -7560,6 +7744,11 @@ Point getOrigin(final GridColumn column, final GridItem item) { } if (colIter.isVisible()) { + // When laying out a fixed column, only sum widths of + // preceding fixed columns; scrollable ones sit behind it. + if (column != null && column.isFixed() && !colIter.isFixed()) { + continue; + } x += colIter.getWidth(); } } @@ -8143,6 +8332,7 @@ void removeColumnGroup(final GridColumnGroup group) { } } columnGroups = newColumnGroups; + warnedFixedGroups.remove(group); if (columnGroups.length == 0) { computeHeaderHeight(); diff --git a/widgets/grid/org.eclipse.nebula.widgets.grid/src/org/eclipse/nebula/widgets/grid/GridColumn.java b/widgets/grid/org.eclipse.nebula.widgets.grid/src/org/eclipse/nebula/widgets/grid/GridColumn.java index a293d96e61..d23f6c478c 100644 --- a/widgets/grid/org.eclipse.nebula.widgets.grid/src/org/eclipse/nebula/widgets/grid/GridColumn.java +++ b/widgets/grid/org.eclipse.nebula.widgets.grid/src/org/eclipse/nebula/widgets/grid/GridColumn.java @@ -1599,11 +1599,23 @@ public boolean isFixed() { } /** - * Set this column as a fixed column, fixed columns are always drawn at the - * front of the grid regardless of other columns and the current scrolling - * position. - * + * Set this column as a fixed (frozen) column. Fixed columns are always + * drawn at the front of the grid regardless of the current horizontal + * scrolling position; the scrollable columns slide behind them. + * + *

The fully-supported and quality-tested case is a single frozen + * first column (the first column in display order). Marking + * additional or non-leading columns as fixed will render but is not + * guaranteed to handle every grid feature (cell spanning across the + * boundary, complex column groups, etc.).

+ * + *

A frozen column should not belong to a multi-column + * {@link GridColumnGroup} that also contains scrollable members. If it + * does, the grid logs a one-time warning and omits the group header + * from the frozen overlay (the column header itself is still drawn).

+ * * @param fixed if the column should be fixed or not + * @see GridColumn#isFixed() */ public void setFixed(boolean fixed) { checkWidget(); From edb2393b71bcb610f64d9fce8d0eab1ccb3751fe Mon Sep 17 00:00:00 2001 From: Cameron Weick Date: Mon, 20 Apr 2026 14:44:17 +1000 Subject: [PATCH 2/4] grid: wire frozen-column tests into the Maven build Add a Tycho test-plugin pom for org.eclipse.nebula.widgets.grid.test and register the bundle as a child module of widgets/grid so CI runs the new GridFixedColumn_Test alongside the rest of the build. Surefire is intentionally scoped to **/GridFixedColumn_Test.java for now: the legacy *_Test classes in the bundle predate current SWT behavior, JUnit, and Mockito (1.8.4), and have been bit-rotting in the IDE-only .launch configuration for years. Restoring them is a worthwhile but orthogonal effort that should not block the frozen-first-column work this bundle was wired up to validate. While here, harden the freeze tests against a hidden ordering bug: ScrollBar.setSelection is silently clipped to the SWT default maximum of 100 until Grid.updateScrollbars() runs as part of a paint, so a setSelection(150) called before the first paint never actually scrolled. The new flushPaint / scrollHorizontallyTo helpers force a paint, set the requested selection, and return the actual clipped value; tests assert on the real delta and refuse to silently pass at scroll=0. Signed-off-by: Cameron Weick --- .../pom.xml | 43 +++++++ .../widgets/grid/GridFixedColumn_Test.java | 105 +++++++++++++++--- widgets/grid/pom.xml | 1 + 3 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 widgets/grid/org.eclipse.nebula.widgets.grid.test/pom.xml diff --git a/widgets/grid/org.eclipse.nebula.widgets.grid.test/pom.xml b/widgets/grid/org.eclipse.nebula.widgets.grid.test/pom.xml new file mode 100644 index 0000000000..354fb1ec7e --- /dev/null +++ b/widgets/grid/org.eclipse.nebula.widgets.grid.test/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + + org.eclipse.nebula + grid + 1.1.0-SNAPSHOT + + + org.eclipse.nebula.widgets.grid.test + 0.4.0-SNAPSHOT + eclipse-test-plugin + + + + + + org.eclipse.tycho + tycho-surefire-plugin + ${tycho-version} + + + **/GridFixedColumn_Test.java + + + + + + + diff --git a/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java b/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java index d9563b87b8..534092f4ef 100644 --- a/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java +++ b/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java @@ -24,6 +24,7 @@ import org.eclipse.swt.SWT; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; import org.eclipse.swt.widgets.ScrollBar; import org.eclipse.swt.widgets.Shell; import org.junit.After; @@ -80,7 +81,7 @@ public void testGetOrigin_FrozenColumn_PinnedAcrossScroll() { GridItem[] items = createGridItems( grid, 20, 0 ); columns[ 0 ].setFixed( true ); - horizontalBar.setSelection( 150 ); + scrollHorizontallyTo( 150 ); Point origin = grid.getOrigin( columns[ 0 ], items[ 0 ] ); @@ -92,13 +93,16 @@ public void testGetOrigin_NonFrozenColumn_StillScrolls() { GridColumn[] columns = createGridColumns( grid, 10, SWT.NONE ); GridItem[] items = createGridItems( grid, 20, 0 ); columns[ 0 ].setFixed( true ); + flushPaint(); Point unscrolled = grid.getOrigin( columns[ 3 ], items[ 0 ] ); - horizontalBar.setSelection( 150 ); + int actualScroll = scrollHorizontallyTo( 150 ); Point scrolled = grid.getOrigin( columns[ 3 ], items[ 0 ] ); + assertTrue( "scrollbar must accept some non-zero selection for this test to be meaningful", + actualScroll > 0 ); assertEquals( "Non-frozen column origin must shift by scroll delta", - unscrolled.x - 150, scrolled.x ); + unscrolled.x - actualScroll, scrolled.x ); } @Test @@ -109,7 +113,9 @@ public void testGetColumn_FrozenOverlayWinsHitTesting() { frozen.setFixed( true ); // Force the overlay to be active by scrolling past the frozen offset. - horizontalBar.setSelection( 150 ); + int actualScroll = scrollHorizontallyTo( 150 ); + assertTrue( "scrollbar must accept some non-zero selection for the overlay to be active", + actualScroll > 0 ); // The frozen column has width 20 (col_0 -> 20 * (0+1)), so a hit at x=5 // falls inside the overlay region. @@ -124,7 +130,9 @@ public void testGetColumn_OutsideFrozenOverlay_ResolvesScrolledColumn() { createGridItems( grid, 20, 0 ); columns[ 0 ].setFixed( true ); - horizontalBar.setSelection( 150 ); + int actualScroll = scrollHorizontallyTo( 150 ); + assertTrue( "scrollbar must accept some non-zero selection for this test to be meaningful", + actualScroll > 0 ); GridColumn hit = grid.getColumn( new Point( 100, 5 ) ); assertNotNull( hit ); @@ -132,6 +140,79 @@ public void testGetColumn_OutsideFrozenOverlay_ResolvesScrolledColumn() { hit == columns[ 0 ] ); } + /** + * Drains pending paint events so {@link Grid#updateScrollbars()} runs at + * least once; otherwise {@code horizontalBar.getMaximum()} is still SWT's + * default of 100 and any larger {@code setSelection} call is silently + * clipped to roughly 0. + */ + private void flushPaint() { + grid.redraw(); + grid.update(); + while( display.readAndDispatch() ) { + // drain + } + } + + /** + * Programmatically scrolls horizontally to {@code targetPixels}, returning + * the actual selection the scrollbar settled on after clipping. Tests + * should treat the returned value as the source of truth, since SWT may + * clip {@code targetPixels} based on the current maximum/thumb. + */ + private int scrollHorizontallyTo( int targetPixels ) { + flushPaint(); + horizontalBar.setSelection( targetPixels ); + flushPaint(); + return horizontalBar.getSelection(); + } + + @Test + public void testTreeToggle_OnFrozenColumn_RespondsWhenScrolled() { + grid.setHeaderVisible( true ); + + GridColumn frozen = new GridColumn( grid, SWT.NONE ); + frozen.setText( "tree" ); + frozen.setWidth( 160 ); + frozen.setTree( true ); + frozen.setFixed( true ); + + for( int c = 1; c < 6; c++ ) { + GridColumn other = new GridColumn( grid, SWT.NONE ); + other.setText( "c" + c ); + other.setWidth( 120 ); + } + + GridItem root = new GridItem( grid, SWT.NONE ); + root.setText( 0, "root" ); + GridItem child = new GridItem( root, SWT.NONE ); + child.setText( 0, "child" ); + root.setExpanded( false ); + + shell.setSize( 320, 300 ); + shell.layout(); + int actualScroll = scrollHorizontallyTo( 200 ); + assertTrue( "scrollbar must accept some non-zero selection so the overlay is active", + actualScroll > 0 ); + + assertFalse( "precondition: root should be collapsed", root.isExpanded() ); + + // Click roughly where the toggle is rendered: a few pixels in from the + // left edge of the frozen overlay, vertically centred on the first row. + int toggleX = 8; + int rowY = grid.getHeaderHeight() + ( root.getHeight() / 2 ); + Event mouseDown = new Event(); + mouseDown.type = SWT.MouseDown; + mouseDown.button = 1; + mouseDown.x = toggleX; + mouseDown.y = rowY; + mouseDown.widget = grid; + grid.notifyListeners( SWT.MouseDown, mouseDown ); + + assertTrue( "Clicking the toggle on the frozen column while scrolled should expand the row", + root.isExpanded() ); + } + @Test public void testGroupSpanning_AcrossFreezeBoundary_WarnsOnceAndDegrades() { grid.setHeaderVisible( true ); @@ -154,23 +235,15 @@ public void testGroupSpanning_AcrossFreezeBoundary_WarnsOnceAndDegrades() { } createGridItems( grid, 5, 0 ); - horizontalBar.setSelection( 150 ); + scrollHorizontallyTo( 150 ); PrintStream originalErr = System.err; ByteArrayOutputStream captured = new ByteArrayOutputStream(); System.setErr( new PrintStream( captured ) ); try { - grid.redraw(); - grid.update(); - while( display.readAndDispatch() ) { - // drain - } + flushPaint(); // Trigger a second paint to ensure the warning only fires once. - grid.redraw(); - grid.update(); - while( display.readAndDispatch() ) { - // drain - } + flushPaint(); } finally { System.setErr( originalErr ); } diff --git a/widgets/grid/pom.xml b/widgets/grid/pom.xml index 4cb399d0c4..f1a3381ad1 100644 --- a/widgets/grid/pom.xml +++ b/widgets/grid/pom.xml @@ -21,6 +21,7 @@ org.eclipse.nebula.widgets.grid.css org.eclipse.nebula.widgets.grid.css.feature org.eclipse.nebula.widgets.grid.example.e4 + org.eclipse.nebula.widgets.grid.test From b549a0d96b92e195f43972f5121810fa52ca6b2f Mon Sep 17 00:00:00 2001 From: Cameron Weick Date: Tue, 21 Apr 2026 17:32:49 +1000 Subject: [PATCH 3/4] Grid: write scroll state atomically in frozen-column tests On headless Linux GTK the SWT widget is never realized during the synthetic redraw/update/readAndDispatch cycle used by these tests, so Grid.updateScrollbars() never runs and the horizontal scrollbar stays at its defaults (visible=false, max=1, thumb=1). GTK treats setSelection on an invisible scrollbar as a silent no-op, which caused three frozen-column tests to fail on CI while passing everywhere else. Switch the scroll helper to ScrollBar.setValues with explicit setVisible(true), which atomically assigns selection/max/thumb and bypasses the realization dependency entirely. Fixes three CI failures in PR #696. Signed-off-by: Cameron Weick --- .../widgets/grid/GridFixedColumn_Test.java | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java b/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java index 534092f4ef..cbbd2691aa 100644 --- a/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java +++ b/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java @@ -141,10 +141,12 @@ public void testGetColumn_OutsideFrozenOverlay_ResolvesScrolledColumn() { } /** - * Drains pending paint events so {@link Grid#updateScrollbars()} runs at - * least once; otherwise {@code horizontalBar.getMaximum()} is still SWT's - * default of 100 and any larger {@code setSelection} call is silently - * clipped to roughly 0. + * Drains pending paint events so {@link Grid#updateScrollbars()} has a + * chance to run on platforms where the widget is realized during event + * dispatch. This is enough on Windows; on headless Linux GTK the widget + * is never realized here, so tests cannot rely on it — + * {@link #scrollHorizontallyTo} compensates by writing scroll state + * atomically via {@link ScrollBar#setValues}. */ private void flushPaint() { grid.redraw(); @@ -159,11 +161,26 @@ private void flushPaint() { * the actual selection the scrollbar settled on after clipping. Tests * should treat the returned value as the source of truth, since SWT may * clip {@code targetPixels} based on the current maximum/thumb. + * + *

On headless Linux GTK (the Nebula CI environment) the SWT widget is + * never realized during the synthetic {@code redraw/update/readAndDispatch} + * cycle used here, so {@code Grid.updateScrollbars()} never runs and the + * horizontal scrollbar stays at its defaults ({@code visible=false, + * max=1, thumb=1}). GTK treats {@code setSelection} on an invisible + * scrollbar as a silent no-op, which is what caused the three + * frozen-column tests to fail on CI while passing everywhere else. + * + *

{@link ScrollBar#setValues} atomically assigns selection, max and + * thumb, so the requested selection cannot be clipped by a stale max; + * {@link ScrollBar#setVisible} first makes the scrollbar writable on + * GTK. This gives the test a deterministic scroll state regardless of + * whether Grid's paint machinery has had a chance to run. */ private int scrollHorizontallyTo( int targetPixels ) { flushPaint(); - horizontalBar.setSelection( targetPixels ); - flushPaint(); + horizontalBar.setVisible( true ); + int max = Math.max( targetPixels * 4, 2000 ); + horizontalBar.setValues( targetPixels, 0, max, 100, 10, 100 ); return horizontalBar.getSelection(); } From 19dd1e791388a74c1409e743bc210190a3b3ba4c Mon Sep 17 00:00:00 2001 From: Cameron Weick Date: Wed, 22 Apr 2026 11:12:12 +1000 Subject: [PATCH 4/4] [grid] Add tree-toggle scenario to frozen column snippet Extend GridFrozenFirstColumnSnippet with a third section that demonstrates the freeze + tree interaction interactively: a frozen first column configured as the tree column, with expandable parent rows and a caption telling the reader what to do to verify the behaviour. This is the scenario that motivated the original bug report. Without freeze-aware hit-testing, clicking an expand/collapse toggle while the grid is horizontally scrolled routes the click to the column underneath the overlay and the toggle appears unresponsive. The snippet now lets a reviewer reproduce the original symptom and confirm the fix. The same scenario is also covered by an automated regression test, GridFixedColumn_Test.testTreeToggle_OnFrozenColumn_RespondsWhenScrolled. Refs #695 Signed-off-by: Cameron Weick --- .../grid/GridFrozenFirstColumnSnippet.java | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/examples/org.eclipse.nebula.snippets/src/org/eclipse/nebula/snippets/grid/GridFrozenFirstColumnSnippet.java b/examples/org.eclipse.nebula.snippets/src/org/eclipse/nebula/snippets/grid/GridFrozenFirstColumnSnippet.java index 85c4c35100..ba11963c74 100644 --- a/examples/org.eclipse.nebula.snippets/src/org/eclipse/nebula/snippets/grid/GridFrozenFirstColumnSnippet.java +++ b/examples/org.eclipse.nebula.snippets/src/org/eclipse/nebula/snippets/grid/GridFrozenFirstColumnSnippet.java @@ -23,17 +23,26 @@ import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Label; import org.eclipse.swt.widgets.Shell; import org.eclipse.swt.widgets.Text; /** * Demonstrates a frozen first column in {@link Grid}. * - *

Two scenarios are shown side by side:

+ *

Three scenarios are shown stacked top-to-bottom:

*
    *
  • Plain frozen column — the first column stays visible while * horizontal scrolling moves the rest of the columns behind it. The first * column is also editable to verify {@link GridEditor} positioning.
  • + *
  • Frozen tree column with expand/collapse toggles — the + * frozen first column is also the tree column. Scroll horizontally so + * the rest of the grid moves behind the frozen column, then click the + * toggle on a parent row: the row should still expand or collapse. This + * is the case that motivated the original bug report — without + * the freeze-aware hit-testing, toggle clicks were routed to the + * scrolled column underneath the overlay and the toggle appeared + * unresponsive.
  • *
  • Frozen column inside a column group — the frozen first * column shares a {@link GridColumnGroup} with scrollable columns. This * configuration is unsupported; the grid logs a one-time warning to @@ -51,9 +60,10 @@ public static void main(String[] args) { Shell shell = new Shell(display); shell.setText("Grid - Frozen First Column"); shell.setLayout(new GridLayout(1, false)); - shell.setSize(700, 500); + shell.setSize(800, 720); createSimpleSection(shell); + createTreeSection(shell); createGroupSection(shell); shell.open(); @@ -97,6 +107,52 @@ private static void createSimpleSection(Composite parent) { attachFirstColumnEditor(grid); } + private static void createTreeSection(Composite parent) { + Composite section = new Composite(parent, SWT.NONE); + section.setLayout(new GridLayout(1, false)); + section.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + Label caption = new Label(section, SWT.WRAP); + caption.setText("Frozen tree column: scroll horizontally with the bottom scrollbar, " + + "then click an expand/collapse toggle on the frozen column. " + + "Without freeze-aware hit-testing, the click would land on the column " + + "underneath the overlay and the toggle would appear unresponsive."); + caption.setLayoutData(new GridData(SWT.FILL, SWT.BEGINNING, true, false)); + + Grid grid = new Grid(section, SWT.BORDER | SWT.V_SCROLL | SWT.H_SCROLL); + grid.setHeaderVisible(true); + grid.setLinesVisible(true); + grid.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + + GridColumn frozen = new GridColumn(grid, SWT.NONE); + frozen.setText("Tree (frozen)"); + frozen.setWidth(180); + frozen.setTree(true); + frozen.setFixed(true); + + for (int c = 1; c < COLUMN_COUNT; c++) { + GridColumn column = new GridColumn(grid, SWT.NONE); + column.setText("Column " + c); + column.setWidth(120); + } + + for (int p = 0; p < 5; p++) { + GridItem parentItem = new GridItem(grid, SWT.NONE); + parentItem.setText(0, "Parent " + (p + 1)); + for (int c = 1; c < COLUMN_COUNT; c++) { + parentItem.setText(c, "p" + (p + 1) + "c" + c); + } + for (int ch = 0; ch < 3; ch++) { + GridItem child = new GridItem(parentItem, SWT.NONE); + child.setText(0, "Child " + (ch + 1)); + for (int c = 1; c < COLUMN_COUNT; c++) { + child.setText(c, "p" + (p + 1) + "ch" + (ch + 1) + "c" + c); + } + } + parentItem.setExpanded(p == 0); + } + } + private static void createGroupSection(Composite parent) { Composite section = new Composite(parent, SWT.NONE); section.setLayout(new GridLayout(1, false));