Skip to content

Commit 1891c6e

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 #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: #1632 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 16e501f commit 1891c6e

File tree

5 files changed

+225
-13
lines changed

5 files changed

+225
-13
lines changed

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
*/
@@ -683,8 +692,21 @@ void drawBody(GC gc, Rectangle bounds, int state) {
683692
}
684693

685694
void drawClose(GC gc, Rectangle closeRect, int closeImageState) {
695+
drawClose(gc, closeRect, closeImageState, false);
696+
}
697+
698+
void drawClose(GC gc, Rectangle closeRect, int closeImageState, boolean showDirtyIndicator) {
686699
if (closeRect.width == 0 || closeRect.height == 0) return;
687700

701+
// When dirty and not hovered/pressed, draw bullet instead of X
702+
if (showDirtyIndicator) {
703+
int maskedState = closeImageState & (SWT.HOT | SWT.SELECTED | SWT.BACKGROUND);
704+
if (maskedState != SWT.HOT && maskedState != SWT.SELECTED) {
705+
drawDirtyIndicator(gc, closeRect);
706+
return;
707+
}
708+
}
709+
688710
// draw X with length of this constant
689711
final int lineLength = 8;
690712
int x = closeRect.x + Math.max(1, (closeRect.width-lineLength)/2);
@@ -715,6 +737,17 @@ void drawClose(GC gc, Rectangle closeRect, int closeImageState) {
715737
gc.setForeground(originalForeground);
716738
}
717739

740+
private void drawDirtyIndicator(GC gc, Rectangle closeRect) {
741+
int diameter = 8;
742+
int x = closeRect.x + (closeRect.width - diameter) / 2;
743+
int y = closeRect.y + (closeRect.height - diameter) / 2;
744+
y += parent.onBottom ? -1 : 1;
745+
Color originalBackground = gc.getBackground();
746+
gc.setBackground(gc.getForeground());
747+
gc.fillOval(x, y, diameter, diameter);
748+
gc.setBackground(originalBackground);
749+
}
750+
718751
private void drawCloseLines(GC gc, int x, int y, int lineLength, boolean hot) {
719752
if (hot) {
720753
gc.setLineWidth(gc.getLineWidth() + 2);
@@ -1061,7 +1094,7 @@ void drawSelected(int itemIndex, GC gc, Rectangle bounds, int state ) {
10611094
// draw Image
10621095
Rectangle trim = computeTrim(itemIndex, SWT.NONE, 0, 0, 0, 0);
10631096
int xDraw = x - trim.x;
1064-
if (parent.single && shouldDrawCloseIcon(item)) xDraw += item.closeRect.width;
1097+
if (parent.single && shouldAllocateCloseRect(item)) xDraw += item.closeRect.width;
10651098
Image image = item.getImage();
10661099
if (image != null && !image.isDisposed() && parent.showSelectedImage) {
10671100
Rectangle imageBounds = image.getBounds();
@@ -1109,15 +1142,17 @@ void drawSelected(int itemIndex, GC gc, Rectangle bounds, int state ) {
11091142
gc.setBackground(orginalBackground);
11101143
}
11111144
}
1112-
if (shouldDrawCloseIcon(item)) drawClose(gc, item.closeRect, item.closeImageState);
1145+
if (shouldAllocateCloseRect(item)) {
1146+
drawClose(gc, item.closeRect, item.closeImageState, shouldDrawDirtyIndicator(item));
1147+
}
11131148
}
11141149
}
11151150

11161151
private int getLeftTextMargin(CTabItem item) {
11171152
int margin = 0;
11181153
if (shouldApplyLargeTextPadding(parent)) {
11191154
margin += getLargeTextPadding(item);
1120-
if (shouldDrawCloseIcon(item)) {
1155+
if (shouldAllocateCloseRect(item)) {
11211156
margin -= item.closeRect.width / 2;
11221157
}
11231158
}
@@ -1248,7 +1283,7 @@ void drawUnselected(int index, GC gc, Rectangle bounds, int state) {
12481283
Rectangle imageBounds = image.getBounds();
12491284
// only draw image if it won't overlap with close button
12501285
int maxImageWidth = x + width - xDraw - (trim.width + trim.x);
1251-
if (shouldDrawCloseIcon(item)) {
1286+
if (shouldAllocateCloseRect(item)) {
12521287
maxImageWidth -= item.closeRect.width + INTERNAL_SPACING;
12531288
}
12541289
if (imageBounds.width < maxImageWidth) {
@@ -1264,7 +1299,7 @@ void drawUnselected(int index, GC gc, Rectangle bounds, int state) {
12641299
// draw Text
12651300
xDraw += getLeftTextMargin(item);
12661301
int textWidth = x + width - xDraw - (trim.width + trim.x);
1267-
if (shouldDrawCloseIcon(item)) {
1302+
if (shouldAllocateCloseRect(item)) {
12681303
textWidth -= item.closeRect.width + INTERNAL_SPACING;
12691304
}
12701305
if (textWidth > 0) {
@@ -1281,8 +1316,10 @@ void drawUnselected(int index, GC gc, Rectangle bounds, int state) {
12811316
gc.drawText(item.shortenedText, xDraw, textY, FLAGS);
12821317
gc.setFont(gcFont);
12831318
}
1284-
// draw close
1285-
if (shouldDrawCloseIcon(item)) drawClose(gc, item.closeRect, item.closeImageState);
1319+
// draw close or dirty indicator
1320+
if (shouldAllocateCloseRect(item)) {
1321+
drawClose(gc, item.closeRect, item.closeImageState, shouldDrawDirtyIndicator(item));
1322+
}
12861323
}
12871324
}
12881325

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 Snippet394 {
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)