Skip to content

Commit 72bae05

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 12b37f8 commit 72bae05

File tree

5 files changed

+225
-12
lines changed

5 files changed

+225
-12
lines changed

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

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ public class CTabFolder extends Composite {
200200
// close, min/max and chevron buttons
201201
boolean showClose = false;
202202
boolean showUnselectedClose = true;
203+
boolean dirtyIndicatorStyle = false;
203204

204205
boolean showMin = false;
205206
boolean minimized = false;
@@ -2794,7 +2795,7 @@ boolean setItemLocation(GC gc) {
27942795
item.x = leftItemEdge;
27952796
item.y = y;
27962797
item.showing = true;
2797-
if (showClose || item.showClose) {
2798+
if (showClose || item.showClose || (dirtyIndicatorStyle && item.showDirty)) {
27982799
item.closeRect.x = leftItemEdge - renderer.computeTrim(i, SWT.NONE, 0, 0, 0, 0).x;
27992800
item.closeRect.y = onBottom ? size.y - borderBottom - tabHeight + (tabHeight - closeButtonSize.y)/2: borderTop + (tabHeight - closeButtonSize.y)/2;
28002801
}
@@ -2896,7 +2897,7 @@ boolean setItemSize(GC gc) {
28962897
tab.height = tabHeight;
28972898
tab.width = width;
28982899
tab.closeRect.width = tab.closeRect.height = 0;
2899-
if (showClose || tab.showClose) {
2900+
if (showClose || tab.showClose || (dirtyIndicatorStyle && tab.showDirty)) {
29002901
Point closeSize = renderer.computeSize(CTabFolderRenderer.PART_CLOSE_BUTTON, SWT.SELECTED, gc, SWT.DEFAULT, SWT.DEFAULT);
29012902
tab.closeRect.width = closeSize.x;
29022903
tab.closeRect.height = closeSize.y;
@@ -2980,7 +2981,7 @@ boolean setItemSize(GC gc) {
29802981
tab.height = tabHeight;
29812982
tab.width = width;
29822983
tab.closeRect.width = tab.closeRect.height = 0;
2983-
if (showClose || tab.showClose) {
2984+
if (showClose || tab.showClose || (dirtyIndicatorStyle && tab.showDirty)) {
29842985
if (i == selectedIndex || showUnselectedClose) {
29852986
Point closeSize = renderer.computeSize(CTabFolderRenderer.PART_CLOSE_BUTTON, SWT.NONE, gc, SWT.DEFAULT, SWT.DEFAULT);
29862987
tab.closeRect.width = closeSize.x;
@@ -3687,6 +3688,50 @@ public void setUnselectedCloseVisible(boolean visible) {
36873688
showUnselectedClose = visible;
36883689
updateFolder(REDRAW);
36893690
}
3691+
/**
3692+
* Sets whether the dirty indicator uses the close button style. When enabled,
3693+
* dirty items (marked via {@link CTabItem#setShowDirty(boolean)}) show a
3694+
* bullet dot at the close button location instead of the traditional
3695+
* <code>*</code> prefix. The bullet transforms into the close button on hover.
3696+
* <p>
3697+
* The default value is <code>false</code> (traditional <code>*</code> prefix
3698+
* behavior).
3699+
* </p>
3700+
*
3701+
* @param useCloseButtonStyle <code>true</code> to use the bullet-on-close-button
3702+
* style for dirty indicators
3703+
*
3704+
* @exception SWTException <ul>
3705+
* <li>ERROR_WIDGET_DISPOSED - if the receiver has been disposed</li>
3706+
* <li>ERROR_THREAD_INVALID_ACCESS - if not called from the thread that created the receiver</li>
3707+
* </ul>
3708+
*
3709+
* @see CTabItem#setShowDirty(boolean)
3710+
* @since 3.133
3711+
*/
3712+
public void setDirtyIndicatorCloseStyle(boolean useCloseButtonStyle) {
3713+
checkWidget();
3714+
if (dirtyIndicatorStyle == useCloseButtonStyle) return;
3715+
dirtyIndicatorStyle = useCloseButtonStyle;
3716+
updateFolder(REDRAW_TABS);
3717+
}
3718+
/**
3719+
* Returns whether the dirty indicator uses the close button style.
3720+
*
3721+
* @return <code>true</code> if the dirty indicator uses the close button style
3722+
*
3723+
* @exception SWTException <ul>
3724+
* <li>ERROR_WIDGET_DISPOSED - if the receiver has been disposed</li>
3725+
* <li>ERROR_THREAD_INVALID_ACCESS - if not called from the thread that created the receiver</li>
3726+
* </ul>
3727+
*
3728+
* @see #setDirtyIndicatorCloseStyle(boolean)
3729+
* @since 3.133
3730+
*/
3731+
public boolean getDirtyIndicatorCloseStyle() {
3732+
checkWidget();
3733+
return dirtyIndicatorStyle;
3734+
}
36903735
/**
36913736
* Specify whether the image appears on unselected tabs.
36923737
*
@@ -4021,7 +4066,7 @@ String _getToolTip(int x, int y) {
40214066
CTabItem item = getItem(new Point (x, y));
40224067
if (item == null) return null;
40234068
if (!item.showing) return null;
4024-
if ((showClose || item.showClose) && item.closeRect.contains(x, y)) {
4069+
if ((showClose || item.showClose || (dirtyIndicatorStyle && item.showDirty)) && item.closeRect.contains(x, y)) {
40254070
return SWT.getMessage("SWT_Close"); //$NON-NLS-1$
40264071
}
40274072
return item.getToolTipText();

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

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

364364
if (shouldApplyLargeTextPadding(parent)) {
365365
width += getLargeTextPadding(item) * 2;
366-
} else if (shouldDrawCloseIcon(item)) {
366+
} else if (shouldAllocateCloseRect(item)) {
367367
if (width > 0) width += INTERNAL_SPACING;
368368
width += computeSize(PART_CLOSE_BUTTON, SWT.NONE, gc, SWT.DEFAULT, SWT.DEFAULT).x;
369369
}
@@ -383,6 +383,16 @@ private boolean shouldDrawCloseIcon(CTabItem item) {
383383
return showClose && isSelectedOrShowCloseForUnselected;
384384
}
385385

386+
private boolean shouldDrawDirtyIndicator(CTabItem item) {
387+
CTabFolder folder = item.getParent();
388+
if (!folder.dirtyIndicatorStyle || !item.showDirty) return false;
389+
return (item.state & SWT.SELECTED) != 0 || folder.showUnselectedClose;
390+
}
391+
392+
private boolean shouldAllocateCloseRect(CTabItem item) {
393+
return shouldDrawCloseIcon(item) || shouldDrawDirtyIndicator(item);
394+
}
395+
386396
/**
387397
* Returns padding for the text of a tab item when showing images is disabled for the tab folder.
388398
*/
@@ -880,8 +890,21 @@ void drawBody(GC gc, Rectangle bounds, int state) {
880890
}
881891

882892
void drawClose(GC gc, Rectangle closeRect, int closeImageState) {
893+
drawClose(gc, closeRect, closeImageState, false);
894+
}
895+
896+
void drawClose(GC gc, Rectangle closeRect, int closeImageState, boolean showDirtyIndicator) {
883897
if (closeRect.width == 0 || closeRect.height == 0) return;
884898

899+
// When dirty and not hovered/pressed, draw bullet instead of X
900+
if (showDirtyIndicator) {
901+
int maskedState = closeImageState & (SWT.HOT | SWT.SELECTED | SWT.BACKGROUND);
902+
if (maskedState != SWT.HOT && maskedState != SWT.SELECTED) {
903+
drawDirtyIndicator(gc, closeRect);
904+
return;
905+
}
906+
}
907+
885908
// draw X with length of this constant
886909
final int lineLength = 8;
887910
int x = closeRect.x + Math.max(1, (closeRect.width-lineLength)/2);
@@ -912,6 +935,17 @@ void drawClose(GC gc, Rectangle closeRect, int closeImageState) {
912935
gc.setForeground(originalForeground);
913936
}
914937

938+
private void drawDirtyIndicator(GC gc, Rectangle closeRect) {
939+
int diameter = 8;
940+
int x = closeRect.x + (closeRect.width - diameter) / 2;
941+
int y = closeRect.y + (closeRect.height - diameter) / 2;
942+
y += parent.onBottom ? -1 : 1;
943+
Color originalBackground = gc.getBackground();
944+
gc.setBackground(gc.getForeground());
945+
gc.fillOval(x, y, diameter, diameter);
946+
gc.setBackground(originalBackground);
947+
}
948+
915949
private void drawCloseLines(GC gc, int x, int y, int lineLength, boolean hot) {
916950
if (hot) {
917951
gc.setLineWidth(gc.getLineWidth() + 2);
@@ -1420,7 +1454,7 @@ void drawSelected(int itemIndex, GC gc, Rectangle bounds, int state ) {
14201454
// draw Image
14211455
Rectangle trim = computeTrim(itemIndex, SWT.NONE, 0, 0, 0, 0);
14221456
int xDraw = x - trim.x;
1423-
if (parent.single && shouldDrawCloseIcon(item)) xDraw += item.closeRect.width;
1457+
if (parent.single && shouldAllocateCloseRect(item)) xDraw += item.closeRect.width;
14241458
Image image = item.getImage();
14251459
if (image != null && !image.isDisposed() && parent.showSelectedImage) {
14261460
Rectangle imageBounds = image.getBounds();
@@ -1473,15 +1507,17 @@ void drawSelected(int itemIndex, GC gc, Rectangle bounds, int state ) {
14731507
gc.setBackground(orginalBackground);
14741508
}
14751509
}
1476-
if (shouldDrawCloseIcon(item)) drawClose(gc, item.closeRect, item.closeImageState);
1510+
if (shouldAllocateCloseRect(item)) {
1511+
drawClose(gc, item.closeRect, item.closeImageState, shouldDrawDirtyIndicator(item) && !shouldDrawCloseIcon(item));
1512+
}
14771513
}
14781514
}
14791515

14801516
private int getLeftTextMargin(CTabItem item) {
14811517
int margin = 0;
14821518
if (shouldApplyLargeTextPadding(parent)) {
14831519
margin += getLargeTextPadding(item);
1484-
if (shouldDrawCloseIcon(item)) {
1520+
if (shouldAllocateCloseRect(item)) {
14851521
margin -= item.closeRect.width / 2;
14861522
}
14871523
}
@@ -1646,7 +1682,7 @@ void drawUnselected(int index, GC gc, Rectangle bounds, int state) {
16461682
Rectangle imageBounds = image.getBounds();
16471683
// only draw image if it won't overlap with close button
16481684
int maxImageWidth = x + width - xDraw - (trim.width + trim.x);
1649-
if (shouldDrawCloseIcon(item)) {
1685+
if (shouldAllocateCloseRect(item)) {
16501686
maxImageWidth -= item.closeRect.width + INTERNAL_SPACING;
16511687
}
16521688
if (imageBounds.width < maxImageWidth) {
@@ -1662,7 +1698,7 @@ void drawUnselected(int index, GC gc, Rectangle bounds, int state) {
16621698
// draw Text
16631699
xDraw += getLeftTextMargin(item);
16641700
int textWidth = x + width - xDraw - (trim.width + trim.x);
1665-
if (shouldDrawCloseIcon(item)) {
1701+
if (shouldAllocateCloseRect(item)) {
16661702
textWidth -= item.closeRect.width + INTERNAL_SPACING;
16671703
}
16681704
if (textWidth > 0) {
@@ -1679,8 +1715,10 @@ void drawUnselected(int index, GC gc, Rectangle bounds, int state) {
16791715
gc.drawText(item.shortenedText, xDraw, textY, FLAGS);
16801716
gc.setFont(gcFont);
16811717
}
1682-
// draw close
1683-
if (shouldDrawCloseIcon(item)) drawClose(gc, item.closeRect, item.closeImageState);
1718+
// draw close or dirty indicator
1719+
if (shouldAllocateCloseRect(item)) {
1720+
drawClose(gc, item.closeRect, item.closeImageState, shouldDrawDirtyIndicator(item) && !shouldDrawCloseIcon(item));
1721+
}
16841722
}
16851723
}
16861724

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.133
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.133
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)