Skip to content

Commit 46eb0fd

Browse files
vogellaclaude
andcommitted
Add dirty indicator bullet style for CTabFolder tabs
When enabled via CTabFolder.setDirtyIndicatorCloseStyle(true), dirty tabs show a filled circle at the close button location instead of the traditional '*' prefix. The bullet transforms into the close button on hover, matching the behavior of VS Code and similar editors. This is opt-in (disabled by default) to preserve backward compatibility. The feature adds: - CTabFolder.setDirtyIndicatorCloseStyle(boolean) / getDirtyIndicatorCloseStyle() - CTabItem.setShowDirty(boolean) / getShowDirty() - Rendering via fillOval for cross-platform consistency - Snippet391 demonstrating the feature Based on the approach from PR eclipse-platform#1632 by schneidermic0, with fixes for the copy-paste bug, preference toggle support per PMC request, and fillOval rendering instead of drawString for pixel-perfect results. See: eclipse-platform#1632 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cad012b commit 46eb0fd

5 files changed

Lines changed: 225 additions & 13 deletions

File tree

bundles/org.eclipse.swt/Eclipse SWT Custom Widgets/common/org/eclipse/swt/custom/CTabFolder.java

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ public class CTabFolder extends Composite {
199199
// close, min/max and chevron buttons
200200
boolean showClose = false;
201201
boolean showUnselectedClose = true;
202+
boolean dirtyIndicatorStyle = false;
202203

203204
boolean showMin = false;
204205
boolean minimized = false;
@@ -2791,7 +2792,7 @@ boolean setItemLocation(GC gc) {
27912792
item.x = leftItemEdge;
27922793
item.y = y;
27932794
item.showing = true;
2794-
if (showClose || item.showClose) {
2795+
if (showClose || item.showClose || (dirtyIndicatorStyle && item.showDirty)) {
27952796
item.closeRect.x = leftItemEdge - renderer.computeTrim(i, SWT.NONE, 0, 0, 0, 0).x;
27962797
item.closeRect.y = onBottom ? size.y - borderBottom - tabHeight + (tabHeight - closeButtonSize.y)/2: borderTop + (tabHeight - closeButtonSize.y)/2;
27972798
}
@@ -2892,7 +2893,7 @@ boolean setItemSize(GC gc) {
28922893
tab.height = tabHeight;
28932894
tab.width = width;
28942895
tab.closeRect.width = tab.closeRect.height = 0;
2895-
if (showClose || tab.showClose) {
2896+
if (showClose || tab.showClose || (dirtyIndicatorStyle && tab.showDirty)) {
28962897
Point closeSize = renderer.computeSize(CTabFolderRenderer.PART_CLOSE_BUTTON, SWT.SELECTED, gc, SWT.DEFAULT, SWT.DEFAULT);
28972898
tab.closeRect.width = closeSize.x;
28982899
tab.closeRect.height = closeSize.y;
@@ -2976,8 +2977,8 @@ boolean setItemSize(GC gc) {
29762977
tab.height = tabHeight;
29772978
tab.width = width;
29782979
tab.closeRect.width = tab.closeRect.height = 0;
2979-
if (showClose || tab.showClose) {
2980-
if (i == selectedIndex || showUnselectedClose) {
2980+
if (showClose || tab.showClose || (dirtyIndicatorStyle && tab.showDirty)) {
2981+
if (i == selectedIndex || showUnselectedClose || (dirtyIndicatorStyle && tab.showDirty)) {
29812982
Point closeSize = renderer.computeSize(CTabFolderRenderer.PART_CLOSE_BUTTON, SWT.NONE, gc, SWT.DEFAULT, SWT.DEFAULT);
29822983
tab.closeRect.width = closeSize.x;
29832984
tab.closeRect.height = closeSize.y;
@@ -3662,6 +3663,50 @@ public void setUnselectedCloseVisible(boolean visible) {
36623663
showUnselectedClose = visible;
36633664
updateFolder(REDRAW);
36643665
}
3666+
/**
3667+
* Sets whether the dirty indicator uses the close button style. When enabled,
3668+
* dirty items (marked via {@link CTabItem#setShowDirty(boolean)}) show a
3669+
* bullet dot at the close button location instead of the traditional
3670+
* <code>*</code> prefix. The bullet transforms into the close button on hover.
3671+
* <p>
3672+
* The default value is <code>false</code> (traditional <code>*</code> prefix
3673+
* behavior).
3674+
* </p>
3675+
*
3676+
* @param useCloseButtonStyle <code>true</code> to use the bullet-on-close-button
3677+
* style for dirty indicators
3678+
*
3679+
* @exception SWTException <ul>
3680+
* <li>ERROR_WIDGET_DISPOSED - if the receiver has been disposed</li>
3681+
* <li>ERROR_THREAD_INVALID_ACCESS - if not called from the thread that created the receiver</li>
3682+
* </ul>
3683+
*
3684+
* @see CTabItem#setShowDirty(boolean)
3685+
* @since 3.134
3686+
*/
3687+
public void setDirtyIndicatorCloseStyle(boolean useCloseButtonStyle) {
3688+
checkWidget();
3689+
if (dirtyIndicatorStyle == useCloseButtonStyle) return;
3690+
dirtyIndicatorStyle = useCloseButtonStyle;
3691+
updateFolder(REDRAW_TABS);
3692+
}
3693+
/**
3694+
* Returns whether the dirty indicator uses the close button style.
3695+
*
3696+
* @return <code>true</code> if the dirty indicator uses the close button style
3697+
*
3698+
* @exception SWTException <ul>
3699+
* <li>ERROR_WIDGET_DISPOSED - if the receiver has been disposed</li>
3700+
* <li>ERROR_THREAD_INVALID_ACCESS - if not called from the thread that created the receiver</li>
3701+
* </ul>
3702+
*
3703+
* @see #setDirtyIndicatorCloseStyle(boolean)
3704+
* @since 3.134
3705+
*/
3706+
public boolean getDirtyIndicatorCloseStyle() {
3707+
checkWidget();
3708+
return dirtyIndicatorStyle;
3709+
}
36653710
/**
36663711
* Specify whether the image appears on unselected tabs.
36673712
*
@@ -3996,7 +4041,7 @@ String _getToolTip(int x, int y) {
39964041
CTabItem item = getItem(new Point (x, y));
39974042
if (item == null) return null;
39984043
if (!item.showing) return null;
3999-
if ((showClose || item.showClose) && item.closeRect.contains(x, y)) {
4044+
if ((showClose || item.showClose || (dirtyIndicatorStyle && item.showDirty)) && item.closeRect.contains(x, y)) {
40004045
return SWT.getMessage("SWT_Close"); //$NON-NLS-1$
40014046
}
40024047
return item.getToolTipText();

bundles/org.eclipse.swt/Eclipse SWT Custom Widgets/common/org/eclipse/swt/custom/CTabFolderRenderer.java

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ protected Point computeSize (int part, int state, GC gc, int wHint, int hHint) {
265265

266266
if (shouldApplyLargeTextPadding(parent)) {
267267
width += getLargeTextPadding(item) * 2;
268-
} else if (shouldDrawCloseIcon(item)) {
268+
} else if (shouldAllocateCloseRect(item)) {
269269
if (width > 0) width += INTERNAL_SPACING;
270270
width += computeSize(PART_CLOSE_BUTTON, SWT.NONE, gc, SWT.DEFAULT, SWT.DEFAULT).x;
271271
}
@@ -285,6 +285,15 @@ private boolean shouldDrawCloseIcon(CTabItem item) {
285285
return showClose && isSelectedOrShowCloseForUnselected;
286286
}
287287

288+
private boolean shouldDrawDirtyIndicator(CTabItem item) {
289+
CTabFolder folder = item.getParent();
290+
return folder.dirtyIndicatorStyle && item.showDirty;
291+
}
292+
293+
private boolean shouldAllocateCloseRect(CTabItem item) {
294+
return shouldDrawCloseIcon(item) || shouldDrawDirtyIndicator(item);
295+
}
296+
288297
/**
289298
* Returns padding for the text of a tab item when showing images is disabled for the tab folder.
290299
*/
@@ -692,8 +701,21 @@ void drawBody(GC gc, Rectangle bounds, int state) {
692701
}
693702

694703
void drawClose(GC gc, Rectangle closeRect, int closeImageState) {
704+
drawClose(gc, closeRect, closeImageState, false);
705+
}
706+
707+
void drawClose(GC gc, Rectangle closeRect, int closeImageState, boolean showDirtyIndicator) {
695708
if (closeRect.width == 0 || closeRect.height == 0) return;
696709

710+
// When dirty and not hovered/pressed, draw bullet instead of X
711+
if (showDirtyIndicator) {
712+
int maskedState = closeImageState & (SWT.HOT | SWT.SELECTED | SWT.BACKGROUND);
713+
if (maskedState != SWT.HOT && maskedState != SWT.SELECTED) {
714+
drawDirtyIndicator(gc, closeRect);
715+
return;
716+
}
717+
}
718+
697719
// draw X with length of this constant
698720
final int lineLength = 8;
699721
int x = closeRect.x + Math.max(1, (closeRect.width-lineLength)/2);
@@ -724,6 +746,17 @@ void drawClose(GC gc, Rectangle closeRect, int closeImageState) {
724746
gc.setForeground(originalForeground);
725747
}
726748

749+
private void drawDirtyIndicator(GC gc, Rectangle closeRect) {
750+
int diameter = 8;
751+
int x = closeRect.x + (closeRect.width - diameter) / 2;
752+
int y = closeRect.y + (closeRect.height - diameter) / 2;
753+
y += parent.onBottom ? -1 : 1;
754+
Color originalBackground = gc.getBackground();
755+
gc.setBackground(gc.getForeground());
756+
gc.fillOval(x, y, diameter, diameter);
757+
gc.setBackground(originalBackground);
758+
}
759+
727760
private void drawCloseLines(GC gc, int x, int y, int lineLength, boolean hot) {
728761
if (hot) {
729762
gc.setLineWidth(gc.getLineWidth() + 2);
@@ -1070,7 +1103,7 @@ void drawSelected(int itemIndex, GC gc, Rectangle bounds, int state ) {
10701103
// draw Image
10711104
Rectangle trim = computeTrim(itemIndex, SWT.NONE, 0, 0, 0, 0);
10721105
int xDraw = x - trim.x;
1073-
if (parent.single && shouldDrawCloseIcon(item)) xDraw += item.closeRect.width;
1106+
if (parent.single && shouldAllocateCloseRect(item)) xDraw += item.closeRect.width;
10741107
Image image = item.getImage();
10751108
if (image != null && !image.isDisposed() && parent.showSelectedImage) {
10761109
Rectangle imageBounds = image.getBounds();
@@ -1118,15 +1151,17 @@ void drawSelected(int itemIndex, GC gc, Rectangle bounds, int state ) {
11181151
gc.setBackground(orginalBackground);
11191152
}
11201153
}
1121-
if (shouldDrawCloseIcon(item)) drawClose(gc, item.closeRect, item.closeImageState);
1154+
if (shouldAllocateCloseRect(item)) {
1155+
drawClose(gc, item.closeRect, item.closeImageState, shouldDrawDirtyIndicator(item));
1156+
}
11221157
}
11231158
}
11241159

11251160
private int getLeftTextMargin(CTabItem item) {
11261161
int margin = 0;
11271162
if (shouldApplyLargeTextPadding(parent)) {
11281163
margin += getLargeTextPadding(item);
1129-
if (shouldDrawCloseIcon(item)) {
1164+
if (shouldAllocateCloseRect(item)) {
11301165
margin -= item.closeRect.width / 2;
11311166
}
11321167
}
@@ -1264,7 +1299,7 @@ void drawUnselected(int index, GC gc, Rectangle bounds, int state) {
12641299
Rectangle imageBounds = image.getBounds();
12651300
// only draw image if it won't overlap with close button
12661301
int maxImageWidth = x + width - xDraw - (trim.width + trim.x);
1267-
if (shouldDrawCloseIcon(item)) {
1302+
if (shouldAllocateCloseRect(item)) {
12681303
maxImageWidth -= item.closeRect.width + INTERNAL_SPACING;
12691304
}
12701305
if (imageBounds.width < maxImageWidth) {
@@ -1280,7 +1315,7 @@ void drawUnselected(int index, GC gc, Rectangle bounds, int state) {
12801315
// draw Text
12811316
xDraw += getLeftTextMargin(item);
12821317
int textWidth = x + width - xDraw - (trim.width + trim.x);
1283-
if (shouldDrawCloseIcon(item)) {
1318+
if (shouldAllocateCloseRect(item)) {
12841319
textWidth -= item.closeRect.width + INTERNAL_SPACING;
12851320
}
12861321
if (textWidth > 0) {
@@ -1297,8 +1332,10 @@ void drawUnselected(int index, GC gc, Rectangle bounds, int state) {
12971332
gc.drawText(item.shortenedText, xDraw, textY, FLAGS);
12981333
gc.setFont(gcFont);
12991334
}
1300-
// draw close
1301-
if (shouldDrawCloseIcon(item)) drawClose(gc, item.closeRect, item.closeImageState);
1335+
// draw close or dirty indicator
1336+
if (shouldAllocateCloseRect(item)) {
1337+
drawClose(gc, item.closeRect, item.closeImageState, shouldDrawDirtyIndicator(item));
1338+
}
13021339
}
13031340
}
13041341

bundles/org.eclipse.swt/Eclipse SWT Custom Widgets/common/org/eclipse/swt/custom/CTabItem.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public class CTabItem extends Item {
5555
int closeImageState = SWT.BACKGROUND;
5656
int state = SWT.NONE;
5757
boolean showClose = false;
58+
boolean showDirty = false;
5859
boolean showing = false;
5960

6061
/**
@@ -276,6 +277,26 @@ public boolean getShowClose() {
276277
checkWidget();
277278
return showClose;
278279
}
280+
/**
281+
* Returns <code>true</code> to indicate that the receiver is dirty
282+
* (has unsaved changes). When the parent folder's dirty indicator style
283+
* is enabled, dirty items show a bullet dot at the close button location
284+
* instead of the default <code>*</code> prefix.
285+
*
286+
* @return <code>true</code> if the item is marked as dirty
287+
*
288+
* @exception SWTException <ul>
289+
* <li>ERROR_WIDGET_DISPOSED - if the receiver has been disposed</li>
290+
* <li>ERROR_THREAD_INVALID_ACCESS - if not called from the thread that created the receiver</li>
291+
* </ul>
292+
*
293+
* @see CTabFolder#setDirtyIndicatorCloseStyle(boolean)
294+
* @since 3.134
295+
*/
296+
public boolean getShowDirty() {
297+
checkWidget();
298+
return showDirty;
299+
}
279300
/**
280301
* Returns the receiver's tool tip text, or null if it has
281302
* not been set.
@@ -490,6 +511,29 @@ public void setShowClose(boolean close) {
490511
showClose = close;
491512
parent.updateFolder(CTabFolder.REDRAW_TABS);
492513
}
514+
/**
515+
* Marks this item as dirty (having unsaved changes). When the parent
516+
* folder's dirty indicator style is enabled via
517+
* {@link CTabFolder#setDirtyIndicatorCloseStyle(boolean)}, dirty items
518+
* show a bullet dot at the close button location. The bullet transforms
519+
* into the close button on hover.
520+
*
521+
* @param dirty <code>true</code> to mark the item as dirty
522+
*
523+
* @exception SWTException <ul>
524+
* <li>ERROR_WIDGET_DISPOSED - if the receiver has been disposed</li>
525+
* <li>ERROR_THREAD_INVALID_ACCESS - if not called from the thread that created the receiver</li>
526+
* </ul>
527+
*
528+
* @see CTabFolder#setDirtyIndicatorCloseStyle(boolean)
529+
* @since 3.134
530+
*/
531+
public void setShowDirty(boolean dirty) {
532+
checkWidget();
533+
if (showDirty == dirty) return;
534+
showDirty = dirty;
535+
parent.updateFolder(CTabFolder.REDRAW_TABS);
536+
}
493537
/**
494538
* Sets the text to display on the tab.
495539
* A carriage return '\n' allows to display multi line text.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2026 Contributors to the Eclipse Foundation
3+
*
4+
* This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Public License 2.0
6+
* which accompanies this distribution, and is available at
7+
* https://www.eclipse.org/legal/epl-2.0/
8+
*
9+
* SPDX-License-Identifier: EPL-2.0
10+
*******************************************************************************/
11+
package org.eclipse.swt.snippets;
12+
13+
/*
14+
* CTabFolder example: dirty indicator using bullet dot on close button
15+
*
16+
* For a list of all SWT example snippets see
17+
* http://www.eclipse.org/swt/snippets/
18+
*/
19+
import org.eclipse.swt.*;
20+
import org.eclipse.swt.custom.*;
21+
import org.eclipse.swt.layout.*;
22+
import org.eclipse.swt.widgets.*;
23+
24+
public class Snippet393 {
25+
public static void main(String[] args) {
26+
Display display = new Display();
27+
Shell shell = new Shell(display);
28+
shell.setLayout(new GridLayout());
29+
shell.setText("CTabFolder Dirty Indicator");
30+
31+
CTabFolder folder = new CTabFolder(shell, SWT.CLOSE | SWT.BORDER);
32+
folder.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));
33+
folder.setDirtyIndicatorCloseStyle(true);
34+
35+
for (int i = 0; i < 4; i++) {
36+
CTabItem item = new CTabItem(folder, SWT.NONE);
37+
item.setText("Tab " + i);
38+
Text text = new Text(folder, SWT.MULTI | SWT.WRAP);
39+
text.setText("Content for tab " + i);
40+
item.setControl(text);
41+
}
42+
43+
// Mark tabs 0 and 2 as dirty
44+
folder.getItem(0).setShowDirty(true);
45+
folder.getItem(2).setShowDirty(true);
46+
folder.setSelection(0);
47+
48+
Button toggleButton = new Button(shell, SWT.PUSH);
49+
toggleButton.setText("Toggle dirty on selected tab");
50+
toggleButton.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
51+
toggleButton.addListener(SWT.Selection, e -> {
52+
CTabItem selected = folder.getSelection();
53+
if (selected != null) {
54+
selected.setShowDirty(!selected.getShowDirty());
55+
}
56+
});
57+
58+
shell.setSize(400, 300);
59+
shell.open();
60+
while (!shell.isDisposed()) {
61+
if (!display.readAndDispatch())
62+
display.sleep();
63+
}
64+
display.dispose();
65+
}
66+
}

tests/org.eclipse.swt.tests/JUnit Tests/org/eclipse/swt/tests/junit/Test_org_eclipse_swt_custom_CTabItem.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
package org.eclipse.swt.tests.junit;
1515

1616
import static org.junit.jupiter.api.Assertions.assertEquals;
17+
import static org.junit.jupiter.api.Assertions.assertFalse;
18+
import static org.junit.jupiter.api.Assertions.assertTrue;
1719

1820
import org.eclipse.swt.SWT;
1921
import org.eclipse.swt.custom.CTabFolder;
@@ -76,4 +78,22 @@ public void test_setSelectionForegroundLorg_eclipse_swt_graphics_Color() {
7678
cTabItem.setSelectionForeground(null);
7779
assertEquals(red, cTabItem.getSelectionForeground());
7880
}
81+
82+
@Test
83+
public void test_setShowDirty() {
84+
assertFalse(cTabItem.getShowDirty());
85+
cTabItem.setShowDirty(true);
86+
assertTrue(cTabItem.getShowDirty());
87+
cTabItem.setShowDirty(false);
88+
assertFalse(cTabItem.getShowDirty());
89+
}
90+
91+
@Test
92+
public void test_dirtyIndicatorCloseStyle() {
93+
assertFalse(cTabFolder.getDirtyIndicatorCloseStyle());
94+
cTabFolder.setDirtyIndicatorCloseStyle(true);
95+
assertTrue(cTabFolder.getDirtyIndicatorCloseStyle());
96+
cTabFolder.setDirtyIndicatorCloseStyle(false);
97+
assertFalse(cTabFolder.getDirtyIndicatorCloseStyle());
98+
}
7999
}

0 commit comments

Comments
 (0)