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:

+ * + */ +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