diff --git a/bundles/org.eclipse.swt/Eclipse SWT Custom Widgets/common/org/eclipse/swt/custom/CTabFolder.java b/bundles/org.eclipse.swt/Eclipse SWT Custom Widgets/common/org/eclipse/swt/custom/CTabFolder.java index c89e06e90a2..23820dacd77 100644 --- a/bundles/org.eclipse.swt/Eclipse SWT Custom Widgets/common/org/eclipse/swt/custom/CTabFolder.java +++ b/bundles/org.eclipse.swt/Eclipse SWT Custom Widgets/common/org/eclipse/swt/custom/CTabFolder.java @@ -175,6 +175,7 @@ public class CTabFolder extends Composite { Listener tabControlZoomListener; boolean ignoreTraverse; boolean useDefaultRenderer; + boolean paintedBackgroundsPending; /* External Listener management */ CTabFolder2Listener[] folderListeners = new CTabFolder2Listener[0]; @@ -2147,6 +2148,18 @@ void onPaint(Event event) { gc.setFont(gcFont); gc.setForeground(gcForeground); gc.setBackground(gcBackground); + + if (!useDefaultRenderer && !paintedBackgroundsPending && controls != null) { + Rectangle damage = event.getBounds(); + for (Control control : controls) { + // a custom renderer can change its colors with only a redraw + if (!control.isDisposed() && damage.intersects(control.getBounds())) { + paintedBackgroundsPending = true; + getDisplay().asyncExec(this::updatePaintedBackgrounds); + break; + } + } + } } void onResize(Event event) { @@ -3268,6 +3281,8 @@ public void setRenderer(CTabFolderRenderer renderer) { if (useDefaultRenderer) renderer = new CTabFolderRenderer(this); this.renderer = renderer; updateFolder(REDRAW); + // the renderer decides what the top right controls get as background + updateBkImages(true); } /** * Set the selection to the tab at the specified item. @@ -4107,7 +4122,8 @@ void updateBkImages(boolean colorChanged) { if (wrapped || gradientColors == null) { bkImageBounds[i]=null; control.setBackgroundImage(null); - control.setBackground(getBackground()); + // a custom renderer may paint anything behind the control + control.setBackground(useDefaultRenderer ? getBackground() : getPaintedColor(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)); } else { bounds.width = 10; if (!onBottom) { @@ -4118,11 +4134,21 @@ void updateBkImages(boolean colorChanged) { bounds.y = -1; } bounds.x = 0; + if (bounds.height <= 0) { + // drop the cache, so a skipped color change is not lost + bkImageBounds[i] = null; + continue; + } // do not redraw when only translated: if (colorChanged || !bounds.equals(bkImageBounds[i])) { bkImageBounds[i] = bounds; if (controlBkImages[i] != null) controlBkImages[i].dispose(); - controlBkImages[i] = new Image(control.getDisplay(), (gc, imageWidth, imageHeight) -> renderer.draw(CTabFolderRenderer.PART_BACKGROUND, 0, bounds, gc), bounds.width, bounds.height); + controlBkImages[i] = new Image(control.getDisplay(), (gc, imageWidth, imageHeight) -> { + // pixels a renderer leaves untouched would stay blank in an image + gc.setBackground(getBackground()); + gc.fillRectangle(0, 0, imageWidth, imageHeight); + renderer.draw(CTabFolderRenderer.PART_BACKGROUND, 0, bounds, gc); + }, bounds.width, bounds.height); control.setBackground(null); control.setBackgroundImage(controlBkImages[i]); } @@ -4133,6 +4159,52 @@ void updateBkImages(boolean colorChanged) { } } +/** Gives the controls with a flat background the color the renderer now paints behind them. */ +void updatePaintedBackgrounds() { + paintedBackgroundsPending = false; + if (isDisposed() || useDefaultRenderer || hovering || controls == null) return; + for (Control control : controls) { + if (control.isDisposed() || control.getBackgroundImage() != null) continue; + Rectangle bounds = control.getBounds(); + Color color = getPaintedColor(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + if (!color.equals(control.getBackground())) control.setBackground(color); + } +} +/** Returns the color the folder paints at the given point, as onPaint draws body and header. */ +Color getPaintedColor(int x, int y) { + Point size = getSize(); + if (x < 0 || y < 0 || x >= size.x || y >= size.y) return getBackground(); + Rectangle bodyRect = new Rectangle(0, 0, size.x, size.y); + // a few pixels around the point, translated, so the renderer still gets the full folder bounds + Image image = new Image(getDisplay(), (gc, imageWidth, imageHeight) -> { + Transform transform = new Transform(gc.getDevice()); + try { + transform.translate(1 - x, 1 - y); + gc.setTransform(transform); + // in user space, so that a renderer saving and restoring the clipping keeps it + gc.setClipping(x - 1, y - 1, imageWidth, imageHeight); + gc.setFont(getFont()); + gc.setForeground(getForeground()); + // pixels a renderer leaves untouched show the widget background on screen + gc.setBackground(getBackground()); + gc.fillRectangle(x - 1, y - 1, imageWidth, imageHeight); + renderer.draw(CTabFolderRenderer.PART_BODY, SWT.BACKGROUND | SWT.FOREGROUND, bodyRect, gc); + gc.setFont(getFont()); + gc.setForeground(getForeground()); + gc.setBackground(getBackground()); + renderer.draw(CTabFolderRenderer.PART_HEADER, SWT.BACKGROUND | SWT.FOREGROUND, bodyRect, gc); + } finally { + transform.dispose(); + } + }, 3, 3); + try { + // the zoom the folder is shown at, so that the renderer is invoked as on screen + ImageData data = image.getImageData(DPIUtil.getZoomForAutoscaleProperty(getShell().getZoom())); + return new Color(data.palette.getRGB(data.getPixel(data.width / 2, data.height / 2))); + } finally { + image.dispose(); + } +} String _getToolTip(int x, int y) { CTabItem item = getItem(new Point (x, y)); if (item == null) return null; diff --git a/examples/org.eclipse.swt.snippets/Snippets.md b/examples/org.eclipse.swt.snippets/Snippets.md index 2255084c73c..6e7a57ccd8c 100644 --- a/examples/org.eclipse.swt.snippets/Snippets.md +++ b/examples/org.eclipse.swt.snippets/Snippets.md @@ -119,6 +119,7 @@ To contribute a new snippet, [create a snippet contribution as a pull request](h - [min and max buttons, close button and image only on selected tab](https://github.com/eclipse-platform/eclipse.platform.swt/tree/master/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet165.java) – [(preview)](https://github.com/eclipse-platform/eclipse.platform.swt/blob/master/examples/org.eclipse.swt.snippets/previews/Snippet165.png "Preview for Snippet 165") - [demonstration of a multi line CTabFolder](https://github.com/eclipse-platform/eclipse.platform.swt/tree/master/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet371.java) – [(preview)](https://github.com/eclipse-platform/eclipse.platform.swt/blob/master/examples/org.eclipse.swt.snippets/previews/Snippet371.png "Preview for Snippet 371") - [dirty indicator using bullet dot on close button with theme switching](https://github.com/eclipse-platform/eclipse.platform.swt/tree/master/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet392.java) +- [background of a top right control that wraps below the tab row](https://github.com/eclipse-platform/eclipse.platform.swt/tree/master/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet394.java) ### **Cursor** - [set the hand cursor into a control](https://github.com/eclipse-platform/eclipse.platform.swt/tree/master/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet44.java) – [(preview)](https://github.com/eclipse-platform/eclipse.platform.swt/blob/master/examples/org.eclipse.swt.snippets/previews/Snippet44.png "Preview for Snippet 44") diff --git a/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet394.java b/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet394.java new file mode 100644 index 00000000000..3a4714d7988 --- /dev/null +++ b/examples/org.eclipse.swt.snippets/src/org/eclipse/swt/snippets/Snippet394.java @@ -0,0 +1,83 @@ +/******************************************************************************* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * 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.swt.snippets; + +/* + * CTabFolder example: background of a top right control that wrapped below the + * tab row. Widen the shell until the tool bar fits next to the tabs. + * + * For a list of all SWT example snippets see + * http://www.eclipse.org/swt/snippets/ + */ +import org.eclipse.swt.*; +import org.eclipse.swt.custom.*; +import org.eclipse.swt.graphics.*; +import org.eclipse.swt.layout.*; +import org.eclipse.swt.widgets.*; + +public class Snippet394 { + static final Color TAB_ROW = new Color(0x21, 0x22, 0x2C); + static final Color BODY = new Color(0x28, 0x2A, 0x36); + + /** Paints tab row and body in two different colors. */ + static class TwoToneRenderer extends CTabFolderRenderer { + TwoToneRenderer(CTabFolder parent) { + super(parent); + } + + @Override + protected void draw(int part, int state, Rectangle bounds, GC gc) { + super.draw(part, state, bounds, gc); + if (part == PART_HEADER) { + int split = bounds.y + parent.getTabHeight() + 3; + gc.setBackground(BODY); + gc.fillRectangle(bounds.x, split, bounds.width, bounds.y + bounds.height - split); + } + } + } + + public static void main(String[] args) { + Display display = new Display(); + Shell shell = new Shell(display); + shell.setText("Wrapped top right control"); + shell.setLayout(new FillLayout()); + + CTabFolder folder = new CTabFolder(shell, SWT.NONE); + folder.setRenderer(new TwoToneRenderer(folder)); + folder.setBackground(TAB_ROW); + folder.setForeground(display.getSystemColor(SWT.COLOR_WHITE)); + + for (String name : new String[] { "Variables", "Breakpoints", "Expressions" }) { + CTabItem item = new CTabItem(folder, SWT.NONE); + item.setText(name); + Composite content = new Composite(folder, SWT.NONE); + content.setBackground(BODY); + item.setControl(content); + } + folder.setSelection(0); + + Composite topRight = new Composite(folder, SWT.NONE); + topRight.setLayout(new FillLayout()); + ToolBar toolBar = new ToolBar(topRight, SWT.FLAT); + for (String text : new String[] { "Collapse", "Expand", "Filter", "Menu" }) { + new ToolItem(toolBar, SWT.PUSH).setText(text); + } + folder.setTopRight(topRight, SWT.RIGHT | SWT.WRAP); + + shell.setSize(360, 200); + shell.open(); + while (!shell.isDisposed()) { + if (!display.readAndDispatch()) + display.sleep(); + } + display.dispose(); + } +} diff --git a/tests/org.eclipse.swt.tests/JUnit Tests/org/eclipse/swt/tests/junit/Test_org_eclipse_swt_custom_CTabFolder.java b/tests/org.eclipse.swt.tests/JUnit Tests/org/eclipse/swt/tests/junit/Test_org_eclipse_swt_custom_CTabFolder.java index 7dcd5b066e8..f709c079e9f 100644 --- a/tests/org.eclipse.swt.tests/JUnit Tests/org/eclipse/swt/tests/junit/Test_org_eclipse_swt_custom_CTabFolder.java +++ b/tests/org.eclipse.swt.tests/JUnit Tests/org/eclipse/swt/tests/junit/Test_org_eclipse_swt_custom_CTabFolder.java @@ -34,11 +34,13 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.function.Function; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.CTabFolder; import org.eclipse.swt.custom.CTabFolder2Listener; import org.eclipse.swt.custom.CTabFolderEvent; +import org.eclipse.swt.custom.CTabFolderRenderer; import org.eclipse.swt.custom.CTabItem; import org.eclipse.swt.custom.SashForm; import org.eclipse.swt.graphics.Color; @@ -46,7 +48,9 @@ import org.eclipse.swt.graphics.FontData; import org.eclipse.swt.graphics.GC; import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.RGB; import org.eclipse.swt.graphics.Rectangle; import org.eclipse.swt.internal.DPIUtil; import org.eclipse.swt.layout.FillLayout; @@ -1284,6 +1288,248 @@ public void test_moveItem_errorCases() { "out-of-range to index must be rejected"); } +/* Paints tab row and body in two colors, neither of them the folder background. */ +private static final RGB TAB_ROW_COLOR = new RGB(255, 0, 0); +private static final RGB BODY_COLOR = new RGB(0, 255, 0); +private static final RGB FOLDER_BACKGROUND = new RGB(0, 0, 255); + +/** Like the IDE's CTabRendering: PART_BACKGROUND gets the header bounds and paints the body below them. */ +private static final class TwoToneRenderer extends CTabFolderRenderer { + RGB bodyColor = BODY_COLOR; + + TwoToneRenderer(CTabFolder parent) { + super(parent); + } + + @Override + protected void draw(int part, int state, Rectangle bounds, GC gc) { + if (part == PART_BACKGROUND) { + gc.setBackground(new Color(TAB_ROW_COLOR)); + gc.fillRectangle(bounds); + int bodyY = bounds.y + bounds.height - 1; + gc.setBackground(new Color(bodyColor)); + gc.fillRectangle(bounds.x, bodyY, bounds.width, parent.getSize().y - bodyY); + return; + } + super.draw(part, state, bounds, gc); + if (part == PART_HEADER) { + draw(PART_BACKGROUND, SWT.NONE, new Rectangle(0, 0, parent.getSize().x, parent.getTabHeight() + 1), gc); + } + } +} + +/** Paints the left half of the folder in the tab row color and the right half in the body color. */ +private static final class LeftRightRenderer extends CTabFolderRenderer { + LeftRightRenderer(CTabFolder parent) { + super(parent); + } + + @Override + protected void draw(int part, int state, Rectangle bounds, GC gc) { + super.draw(part, state, bounds, gc); + if (part == PART_HEADER) { + Point size = parent.getSize(); + gc.setBackground(new Color(TAB_ROW_COLOR)); + gc.fillRectangle(0, 0, size.x / 2, size.y); + gc.setBackground(new Color(BODY_COLOR)); + gc.fillRectangle(size.x / 2, 0, size.x - size.x / 2, size.y); + } + } +} + +/** A custom renderer that leaves PART_BACKGROUND to the built-in implementation. */ +private static final class PlainSubclassRenderer extends CTabFolderRenderer { + PlainSubclassRenderer(CTabFolder parent) { + super(parent); + } +} + +/** The color a control shows: its background image if it has one, else its background. */ +private static RGB effectiveBackground(Control control) { + Image image = control.getBackgroundImage(); + if (image != null) { + ImageData data = image.getImageData(); + return data.palette.getRGB(data.getPixel(0, 0)); + } + return control.getBackground().getRGB(); +} + +private Composite createFolderWithTopRightToolBar(int shellWidth, boolean withGradient) { + return createFolderWithTopRightToolBar(shellWidth, withGradient, TwoToneRenderer::new); +} + +private Composite createFolderWithTopRightToolBar(int shellWidth, boolean withGradient, + Function renderer) { + makeCleanEnvironment(); + shell.setLayout(new FillLayout()); + ctabFolder.setRenderer(renderer.apply(ctabFolder)); + ctabFolder.setBackground(new Color(FOLDER_BACKGROUND)); + if (withGradient) { + ctabFolder.setBackground(new Color[] { new Color(TAB_ROW_COLOR), new Color(BODY_COLOR) }, + new int[] { 100 }, true); + } + for (int i = 0; i < 3; i++) { + CTabItem item = new CTabItem(ctabFolder, SWT.NONE); + item.setText("Tab " + i); + item.setControl(new Composite(ctabFolder, SWT.NONE)); + } + ctabFolder.setSelection(0); + + // the IDE wraps the tool bar in a Composite, which is what carries the background + Composite topRight = new Composite(ctabFolder, SWT.NONE); + topRight.setLayout(new FillLayout()); + ToolBar toolBar = new ToolBar(topRight, SWT.FLAT); + for (int i = 0; i < 6; i++) { + new ToolItem(toolBar, SWT.PUSH).setText("Item " + i); + } + ctabFolder.setTopRight(topRight, SWT.RIGHT | SWT.WRAP); + + shell.setSize(shellWidth, 300); + shell.open(); + SwtTestUtil.processEvents(); + ctabFolder.layout(true, true); + SwtTestUtil.processEvents(); + return topRight; +} + +@Test +public void test_topRightControl_wrappedBelowTabRow_matchesBody() { + Composite toolBar = createFolderWithTopRightToolBar(240, false); + assertTrue(toolBar.getBounds().y > ctabFolder.getTabHeight(), + "tool bar did not wrap below the tab row, bounds " + toolBar.getBounds()); + assertEquals(BODY_COLOR, effectiveBackground(toolBar), + "a wrapped control sits on the body and has to match what the renderer paints there"); + assertNull(toolBar.getBackgroundImage(), "a custom renderer's background is applied as a color"); +} + +@Test +public void test_topRightControl_customRendererWithGradient_keepsGradientImage() { + Composite toolBar = createFolderWithTopRightToolBar(900, true, PlainSubclassRenderer::new); + assertFalse(toolBar.getBounds().y > ctabFolder.getTabHeight(), + "tool bar unexpectedly wrapped, bounds " + toolBar.getBounds()); + assertNotNull(toolBar.getBackgroundImage(), "a control in the tab row keeps the gradient"); +} + +@Test +public void test_topRightControl_wrappedWithGradient_plainSubclass_matchesBody() { + Composite toolBar = createFolderWithTopRightToolBar(240, true, PlainSubclassRenderer::new); + assertTrue(toolBar.getBounds().y > ctabFolder.getTabHeight(), + "tool bar did not wrap below the tab row, bounds " + toolBar.getBounds()); + assertEquals(ctabFolder.getBackground().getRGB(), effectiveBackground(toolBar), + "a renderer that paints like the built-in one has to give the built-in result"); +} + +@Test +public void test_topRightControl_sampledAtItsHorizontalPosition() { + Composite toolBar = createFolderWithTopRightToolBar(900, false, LeftRightRenderer::new); + Rectangle bounds = toolBar.getBounds(); + assertTrue(bounds.x + bounds.width / 2 > ctabFolder.getSize().x / 2, + "tool bar is not in the right half, bounds " + bounds); + assertEquals(BODY_COLOR, effectiveBackground(toolBar), + "a control has to match what the renderer paints where the control is"); +} + +@Test +public void test_topRightControl_rightToLeft_sampledAtItsLogicalPosition() throws InterruptedException { + Composite toolBar = createFolderWithTopRightToolBar(900, false, LeftRightRenderer::new); + ctabFolder.setOrientation(SWT.RIGHT_TO_LEFT); + // sample again in RTL, starting from a color the renderer does not paint + toolBar.setBackground(new Color(FOLDER_BACKGROUND)); + ctabFolder.layout(true, true); + ctabFolder.redraw(); + SwtTestUtil.processEvents(2000, () -> !FOLDER_BACKGROUND.equals(effectiveBackground(toolBar))); + // bounds and renderer drawing share the logical space, the paint GC mirrors both + Rectangle bounds = toolBar.getBounds(); + assertTrue(bounds.x + bounds.width / 2 > ctabFolder.getSize().x / 2, + "tool bar is not in the logical right half, bounds " + bounds); + assertEquals(BODY_COLOR, effectiveBackground(toolBar), + "a control has to match what the renderer paints where the control is"); +} + +@Test +public void test_topRightControl_inTabRow_matchesTabRow() { + Composite toolBar = createFolderWithTopRightToolBar(900, false); + assertFalse(toolBar.getBounds().y > ctabFolder.getTabHeight(), + "tool bar unexpectedly wrapped, bounds " + toolBar.getBounds()); + assertEquals(TAB_ROW_COLOR, effectiveBackground(toolBar), + "a control in the tab row has to match what the renderer paints there"); + assertNull(toolBar.getBackgroundImage(), "a custom renderer's background is applied as a color"); +} + +@Test +public void test_topRightControl_rendererKeepingDefaultBackground_staysOnTheFolderBackground() { + Composite toolBar = createFolderWithTopRightToolBar(240, false, PlainSubclassRenderer::new); + assertEquals(FOLDER_BACKGROUND, effectiveBackground(toolBar), + "a renderer that paints like the built-in one has to leave the folder background"); +} + +@Test +public void test_topRightControl_bottomTabs_sampledAtItsPosition() { + Composite toolBar = createFolderWithTopRightToolBar(900, false, LeftRightRenderer::new); + ctabFolder.setTabPosition(SWT.BOTTOM); + ctabFolder.layout(true, true); + SwtTestUtil.processEvents(); + Rectangle bounds = toolBar.getBounds(); + assertTrue(bounds.y > ctabFolder.getSize().y / 2, "tool bar is not at the bottom, bounds " + bounds); + assertEquals(BODY_COLOR, effectiveBackground(toolBar), + "a control has to match what the renderer paints where the control is"); +} + +@Test +public void test_topRightControl_rendererColorChange_updatesOnRedraw() throws InterruptedException { + TwoToneRenderer[] renderer = new TwoToneRenderer[1]; + Composite toolBar = createFolderWithTopRightToolBar(240, false, folder -> renderer[0] = new TwoToneRenderer(folder)); + assertEquals(BODY_COLOR, effectiveBackground(toolBar)); + RGB changed = new RGB(255, 255, 0); + // like CTabRendering's color setters, which only redraw + renderer[0].bodyColor = changed; + ctabFolder.redraw(); + SwtTestUtil.processEvents(2000, () -> changed.equals(effectiveBackground(toolBar))); + assertEquals(changed, effectiveBackground(toolBar), + "a renderer that changes its colors and redraws has to update the control background"); +} + +@Test +public void test_topRightControl_rendererSetAfterLayout_updatesBackground() { + Composite toolBar = createFolderWithTopRightToolBar(240, false, PlainSubclassRenderer::new); + ctabFolder.setRenderer(new TwoToneRenderer(ctabFolder)); + SwtTestUtil.processEvents(); + assertEquals(BODY_COLOR, effectiveBackground(toolBar), + "a renderer set on a laid out folder has to be asked for the background right away"); +} + +@Test +public void test_topRightControl_defaultRendererWithGradient_keepsFlatBackground() { + makeCleanEnvironment(); + shell.setLayout(new FillLayout()); + ctabFolder.setBackground(new Color(FOLDER_BACKGROUND)); + ctabFolder.setBackground(new Color[] { new Color(TAB_ROW_COLOR), new Color(BODY_COLOR) }, + new int[] { 100 }); + for (int i = 0; i < 3; i++) { + CTabItem item = new CTabItem(ctabFolder, SWT.NONE); + item.setText("A rather long tab title " + i); + item.setControl(new Composite(ctabFolder, SWT.NONE)); + } + ctabFolder.setSelection(0); + Composite topRight = new Composite(ctabFolder, SWT.NONE); + topRight.setLayout(new FillLayout()); + ToolBar toolBar = new ToolBar(topRight, SWT.FLAT); + for (int i = 0; i < 6; i++) { + new ToolItem(toolBar, SWT.PUSH).setText("Item " + i); + } + ctabFolder.setTopRight(topRight, SWT.RIGHT | SWT.WRAP); + shell.setSize(240, 300); + shell.open(); + SwtTestUtil.processEvents(); + ctabFolder.layout(true, true); + SwtTestUtil.processEvents(); + + assertTrue(topRight.getBounds().y > ctabFolder.getTabHeight(), + "tool bar did not wrap below the tab row, bounds " + topRight.getBounds()); + assertNull(topRight.getBackgroundImage(), + "the built-in renderer paints the body flat, a wrapped control needs no image"); +} + /** Layout with a preferred size the test can change at will. */ private static final class FixedSizeLayout extends Layout { int width;