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..ba11963c74
--- /dev/null
+++ b/examples/org.eclipse.nebula.snippets/src/org/eclipse/nebula/snippets/grid/GridFrozenFirstColumnSnippet.java
@@ -0,0 +1,219 @@
+/*******************************************************************************
+ * 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.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Text;
+
+/**
+ * Demonstrates a frozen first column in {@link Grid}.
+ *
+ *
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
+ * stderr and degrades gracefully by omitting the group header from the
+ * frozen overlay.
+ *
+ */
+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(800, 720);
+
+ createSimpleSection(shell);
+ createTreeSection(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 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));
+ 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/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
new file mode 100644
index 0000000000..cbbd2691aa
--- /dev/null
+++ b/widgets/grid/org.eclipse.nebula.widgets.grid.test/src/org/eclipse/nebula/widgets/grid/GridFixedColumn_Test.java
@@ -0,0 +1,274 @@
+/*******************************************************************************
+ * 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.Event;
+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 );
+
+ scrollHorizontallyTo( 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 );
+ flushPaint();
+
+ Point unscrolled = grid.getOrigin( columns[ 3 ], items[ 0 ] );
+ 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 - actualScroll, 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.
+ 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.
+ 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 );
+
+ 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 );
+ assertFalse( "Hit outside the frozen overlay must resolve to a scrolled column",
+ hit == columns[ 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();
+ 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.
+ *
+ * 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.setVisible( true );
+ int max = Math.max( targetPixels * 4, 2000 );
+ horizontalBar.setValues( targetPixels, 0, max, 100, 10, 100 );
+ 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 );
+
+ 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 );
+
+ scrollHorizontallyTo( 150 );
+
+ PrintStream originalErr = System.err;
+ ByteArrayOutputStream captured = new ByteArrayOutputStream();
+ System.setErr( new PrintStream( captured ) );
+ try {
+ flushPaint();
+ // Trigger a second paint to ensure the warning only fires once.
+ flushPaint();
+ } 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();
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