Skip to content

Commit 4104caf

Browse files
basilevsclaudevogella
committed
Text control should not redraw indefinitely
In some scenarios, `Text` control redraws on every event loop iteration. See eclipse-platform/eclipse.platform.ui#3920 Replace native text query in search-field paint with Java-side cache Introduce a searchFieldHasText boolean on Text, refreshed at every SWT.Modify emission point (append, cut, insert, paste/replace, setText, setTextChars, verify-listener replacement, and textDidChange). The helper only queries stringValue when the display is not currently painting, so the Java-side read cannot re-arm the loop. The paint method in drawInteriorWithFrame_inView_searchfield consults the cache instead of calling stringValue on the cell. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-Authored-By: Lars Vogel <Lars.Vogel@vogella.com>
1 parent f03d65d commit 4104caf

2 files changed

Lines changed: 184 additions & 4 deletions

File tree

bundles/org.eclipse.swt/Eclipse SWT/cocoa/org/eclipse/swt/widgets/Text.java

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,18 @@ public class Text extends Scrollable {
7070
long actionSearch, actionCancel;
7171
APPEARANCE lastAppAppearance;
7272

73+
/*
74+
* Cached emptiness of the text for the SWT.SEARCH | SWT.ICON_CANCEL paint
75+
* path only. AppKit's NSSearchFieldCell stringValue selector posts
76+
* setNeedsDisplay: synchronously when invoked during a draw pass, turning
77+
* drawInteriorWithFrame_inView_searchfield's cancel-icon visibility check
78+
* into an infinite redraw loop. This flag is updated at the text-mutation
79+
* entry points (outside any paint), so the paint method never has to query
80+
* native state.
81+
* See https://github.com/eclipse-platform/eclipse.platform.ui/issues/3920
82+
*/
83+
boolean searchFieldHasText;
84+
7385
/**
7486
* The maximum number of characters that can be entered
7587
* into a text widget.
@@ -331,6 +343,7 @@ public void append (String string) {
331343
widget.scrollRangeToVisible (range);
332344
widget.setSelectedRange(range);
333345
}
346+
updateSearchFieldHasText();
334347
if (string.length () != 0) sendEvent (SWT.Modify);
335348
}
336349

@@ -639,6 +652,7 @@ public void cut () {
639652
}
640653
}
641654
Point newSelection = getSelection ();
655+
updateSearchFieldHasText();
642656
if (!cut || !oldSelection.equals (newSelection)) sendEvent (SWT.Modify);
643657
}
644658

@@ -745,11 +759,22 @@ void drawInteriorWithFrame_inView_searchfield (long id, long sel, NSRect cellFra
745759
SWTSearchFieldCell cell = new SWTSearchFieldCell(_cell.id);
746760

747761
if (cell.searchButtonCell() != null) {
748-
cell.searchButtonCell().drawInteriorWithFrame(cell.searchButtonRectForBounds(cellFrame), view);
762+
NSRect searchRect = cell.searchButtonRectForBounds(cellFrame);
763+
cell.searchButtonCell().drawInteriorWithFrame(searchRect, view);
749764
}
750765

751-
if (cell.cancelButtonCell() != null && ((NSSearchField) view).stringValue().length() > 0) {
752-
cell.cancelButtonCell().drawInteriorWithFrame(cell.cancelButtonRectForBounds(cellFrame), view);
766+
NSCell cancelCell = cell.cancelButtonCell();
767+
/*
768+
* FIX: Use the Java-side searchFieldHasText cache rather than calling
769+
* stringValue on the search field during paint. Both NSControl.stringValue
770+
* and NSCell.stringValue dispatch to the same AppKit selector, which
771+
* posts setNeedsDisplay: as a side effect and re-arms the paint loop we
772+
* are trying to complete. See eclipse.platform.ui#3920 and the
773+
* updateSearchFieldHasText() call sites for how the cache is kept current.
774+
*/
775+
if (cancelCell != null && searchFieldHasText) {
776+
NSRect cancelRect = cell.cancelButtonRectForBounds(cellFrame);
777+
cancelCell.drawInteriorWithFrame(cancelRect, view);
753778
}
754779
}
755780

@@ -1433,6 +1458,7 @@ public void insert (String string) {
14331458
}
14341459
widget.textStorage ().replaceCharactersInRange (range, str);
14351460
}
1461+
updateSearchFieldHasText();
14361462
if (string.length () != 0) sendEvent (SWT.Modify);
14371463
}
14381464

@@ -1571,6 +1597,7 @@ void _paste (boolean enableUndo) {
15711597
}
15721598
}
15731599
}
1600+
updateSearchFieldHasText();
15741601
sendEvent (SWT.Modify);
15751602
}
15761603

@@ -2247,6 +2274,7 @@ public void setText (String string) {
22472274
widget.setString (str);
22482275
widget.setSelectedRange(new NSRange());
22492276
}
2277+
updateSearchFieldHasText();
22502278
sendEvent (SWT.Modify);
22512279
}
22522280

@@ -2300,6 +2328,7 @@ public void setTextChars (char[] text) {
23002328
widget.setString (str);
23012329
widget.setSelectedRange(new NSRange());
23022330
}
2331+
updateSearchFieldHasText();
23032332
sendEvent (SWT.Modify);
23042333
}
23052334

@@ -2397,7 +2426,10 @@ boolean shouldChangeTextInRange_replacementString(long id, long sel, long affect
23972426
result = false;
23982427
}
23992428
}
2400-
if (!result) sendEvent (SWT.Modify);
2429+
if (!result) {
2430+
updateSearchFieldHasText();
2431+
sendEvent (SWT.Modify);
2432+
}
24012433
return result;
24022434
}
24032435

@@ -2434,9 +2466,23 @@ void textViewDidChangeSelection(long id, long sel, long aNotification) {
24342466
@Override
24352467
void textDidChange (long id, long sel, long aNotification) {
24362468
if ((style & SWT.SINGLE) != 0) super.textDidChange (id, sel, aNotification);
2469+
updateSearchFieldHasText();
24372470
postEvent (SWT.Modify);
24382471
}
24392472

2473+
/*
2474+
* Refresh searchFieldHasText from the native control. Only safe to call
2475+
* outside a paint pass; querying stringValue during paint re-arms the
2476+
* very loop this cache exists to break (see eclipse.platform.ui#3920).
2477+
*/
2478+
void updateSearchFieldHasText() {
2479+
if ((style & SWT.SEARCH) == 0) return;
2480+
if (display == null) return;
2481+
if (display.isPainting != null && display.isPainting.count() > 0) return;
2482+
NSString value = ((NSControl) view).stringValue();
2483+
searchFieldHasText = value != null && (int) value.length() > 0;
2484+
}
2485+
24402486
@Override
24412487
NSRange textView_willChangeSelectionFromCharacterRange_toCharacterRange (long id, long sel, long aTextView, long oldSelectedCharRange, long newSelectedCharRange) {
24422488
/*

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

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,23 @@
1313
*******************************************************************************/
1414
package org.eclipse.swt.tests.junit;
1515

16+
import static java.lang.System.currentTimeMillis;
17+
import static java.lang.System.nanoTime;
1618
import static org.eclipse.swt.tests.junit.SwtTestUtil.JENKINS_DETECT_ENV_VAR;
1719
import static org.eclipse.swt.tests.junit.SwtTestUtil.JENKINS_DETECT_REGEX;
1820
import static org.junit.jupiter.api.Assertions.assertEquals;
1921
import static org.junit.jupiter.api.Assertions.assertFalse;
2022
import static org.junit.jupiter.api.Assertions.assertThrows;
2123
import static org.junit.jupiter.api.Assertions.assertTrue;
24+
import static org.junit.jupiter.api.Assertions.fail;
25+
import static org.junit.jupiter.api.Assumptions.assumeFalse;
26+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
27+
28+
import java.lang.management.ManagementFactory;
2229

2330
import org.eclipse.swt.SWT;
2431
import org.eclipse.swt.events.ModifyListener;
32+
import org.eclipse.swt.events.PaintListener;
2533
import org.eclipse.swt.events.SegmentListener;
2634
import org.eclipse.swt.events.SelectionEvent;
2735
import org.eclipse.swt.events.SelectionListener;
@@ -30,6 +38,7 @@
3038
import org.eclipse.swt.graphics.Font;
3139
import org.eclipse.swt.graphics.FontData;
3240
import org.eclipse.swt.graphics.Point;
41+
import org.eclipse.swt.layout.FillLayout;
3342
import org.eclipse.swt.widgets.Display;
3443
import org.eclipse.swt.widgets.Event;
3544
import org.eclipse.swt.widgets.Group;
@@ -1376,6 +1385,58 @@ public void test_showSelection() {
13761385
text.showSelection();
13771386
}
13781387

1388+
// Originally reported as https://github.com/eclipse-platform/eclipse.platform.ui/issues/3920
1389+
@Test
1390+
public void test_finiteRedrawCancelButtonWithBackground() {
1391+
if ( text != null ) text.dispose();
1392+
// Style constants are causing
1393+
// org.eclipse.swt.widgets.Text.drawInteriorWithFrame_inView_searchfield(long, long, NSRect, long)
1394+
// to call
1395+
// org.eclipse.swt.internal.cocoa.NSControl.stringValue()
1396+
// which schedules redraw
1397+
text = new Text(shell, SWT.SEARCH | SWT.ICON_CANCEL);
1398+
// Background prevents early exit from drawInteriorWithFrame_inView_searchfield(long, long, NSRect, long)
1399+
text.setBackground(shell.getDisplay().getSystemColor(SWT.COLOR_RED));
1400+
setWidget(text);
1401+
shell.setLayout(new FillLayout());
1402+
text.requestLayout();
1403+
shell.open();
1404+
text.forceFocus();
1405+
waitUntilIdle();
1406+
assertIdle();
1407+
}
1408+
1409+
@Test
1410+
public void test_finiteRedrawCancelButton() {
1411+
if ( text != null ) text.dispose();
1412+
// Style constants are causing
1413+
// org.eclipse.swt.widgets.Text.drawInteriorWithFrame_inView_searchfield(long, long, NSRect, long)
1414+
// to call
1415+
// org.eclipse.swt.internal.cocoa.NSControl.stringValue()
1416+
// which schedules redraw
1417+
text = new Text(shell, SWT.SEARCH | SWT.ICON_CANCEL);
1418+
setWidget(text);
1419+
shell.setLayout(new FillLayout());
1420+
text.requestLayout();
1421+
shell.open();
1422+
text.forceFocus();
1423+
waitUntilIdle();
1424+
assertIdle();
1425+
}
1426+
1427+
@Test
1428+
public void test_finiteRedraw() {
1429+
if ( text != null ) text.dispose();
1430+
text = new Text(shell, SWT.NONE);
1431+
setWidget(text);
1432+
shell.setLayout(new FillLayout());
1433+
text.requestLayout();
1434+
shell.open();
1435+
text.forceFocus();
1436+
waitUntilIdle();
1437+
assertIdle();
1438+
}
1439+
13791440
/* custom */
13801441
Text text;
13811442
String delimiterString;
@@ -1639,4 +1700,77 @@ private void pasteFromClipboard(Text text) throws InterruptedException {
16391700
SwtTestUtil.processEvents(1000, () -> !oldText.equals(text.getText()));
16401701
}
16411702

1703+
private void waitUntilIdle() {
1704+
long hangTimeout = currentTimeMillis() + 1000;
1705+
long lastActive = nanoTime();
1706+
while (currentTimeMillis() < hangTimeout) {
1707+
if (shell.getDisplay().readAndDispatch()) {
1708+
lastActive = nanoTime();
1709+
} else {
1710+
// On GTK, blinking caret animation fires with 60 Hz, can't expect long idle times
1711+
if (lastActive < nanoTime() - 10_000_000) {
1712+
return;
1713+
}
1714+
Thread.yield();
1715+
}
1716+
}
1717+
fail("Unexpected system events keep coming");
1718+
}
1719+
1720+
private void assertIdle() {
1721+
assumeFalse(SwtTestUtil.isGTK4(), "GTK4 bug - 10-40% CPU in idle");
1722+
long[] paintCount = new long[] { 0 };
1723+
long wallNs, cpuNs;
1724+
PaintListener paintListener = ignored -> {
1725+
paintCount[0]++;
1726+
};
1727+
text.addPaintListener(paintListener);
1728+
try {
1729+
Display display = shell.getDisplay();
1730+
var tmx = ManagementFactory.getThreadMXBean();
1731+
assumeTrue(tmx.isThreadCpuTimeSupported() && tmx.isThreadCpuTimeEnabled(),
1732+
"Thread CPU time measurement is not available on this JVM");
1733+
1734+
final int MEASURE_MS = 2000;
1735+
1736+
// Schedule a single one-shot timer for the whole measurement window.
1737+
// When it fires it (a) sets the guard that terminates the loop, and
1738+
// (b) wakes display.sleep() on platforms that would otherwise block
1739+
// indefinitely because they generate no background events.
1740+
final boolean[] done = {false};
1741+
display.timerExec(MEASURE_MS, () -> done[0] = true);
1742+
1743+
long threadId = Thread.currentThread().threadId();
1744+
long cpuBefore = tmx.getThreadCpuTime(threadId);
1745+
long wallStart = System.nanoTime();
1746+
// Additional protection against broken event loop, happens on MacOS SDK 24
1747+
long stopGuard = currentTimeMillis() + MEASURE_MS * 2;
1748+
while (!done[0]) {
1749+
if (stopGuard < currentTimeMillis()) {
1750+
fail("Timer should fire");
1751+
}
1752+
if (!display.readAndDispatch()) {
1753+
// On GTK, blinking caret animation fires with 60 Hz
1754+
// We can't just count busy iterations
1755+
// Hence the actual CPU load measurement below
1756+
display.sleep();
1757+
}
1758+
}
1759+
wallNs = System.nanoTime() - wallStart;
1760+
1761+
cpuNs = tmx.getThreadCpuTime(threadId) - cpuBefore;
1762+
} finally {
1763+
text.removePaintListener(paintListener);
1764+
}
1765+
double cpuFraction = (double) cpuNs / wallNs;
1766+
int maxPaint = 15;
1767+
double maxCPU = 0.05;
1768+
String message = "CPU usage when idle: %.1f%% < %.1f%%. Paint events: %d < %d"
1769+
.formatted(cpuFraction * 100, maxCPU * 100, paintCount[0], maxPaint);
1770+
if (SwtTestUtil.verbose) {
1771+
System.out.println(message);
1772+
}
1773+
assertTrue(cpuFraction < maxCPU && paintCount[0] < maxPaint, message);
1774+
}
1775+
16421776
}

0 commit comments

Comments
 (0)