From 568a117bc73d3ec427acccc4b04045787af6dffc Mon Sep 17 00:00:00 2001 From: Konstantin Nisht Date: Tue, 17 Jun 2025 19:06:56 +0200 Subject: [PATCH] [threading] UX-3484 IJPL-182690: Implement new design for Suvorov Progress GitOrigin-RevId: f2e38ab95049656d2460909bf5f6470cf807d7c2 --- .../messages/DiagnosticBundle.properties | 3 + .../openapi/progress/util/SuvorovProgress.kt | 54 +++ .../openapi/progress/util/ui/NiceOverlayUi.kt | 324 ++++++++++++++++++ .../util/resources/misc/registry.properties | 2 +- 4 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 platform/platform-impl/src/com/intellij/openapi/progress/util/ui/NiceOverlayUi.kt diff --git a/platform/platform-impl/resources/messages/DiagnosticBundle.properties b/platform/platform-impl/resources/messages/DiagnosticBundle.properties index f983a5f7ae4f..2959d584457c 100644 --- a/platform/platform-impl/resources/messages/DiagnosticBundle.properties +++ b/platform/platform-impl/resources/messages/DiagnosticBundle.properties @@ -138,6 +138,9 @@ change.memory.apply=Save and Restart change.memory.exit=Save and Exit change.memory.low=The value should be greater than {0} +freeze.popup.application.is.not.responding={0} is not responding +freeze.popup.dump.threads.suggestion=Dump threads + notification.group.defender.config=Microsoft Defender may affect IDE defender.config.prompt=To avoid performance issues, exclude the IDE and the project folders from the Real-Time Protection:\
{0}
\ diff --git a/platform/platform-impl/src/com/intellij/openapi/progress/util/SuvorovProgress.kt b/platform/platform-impl/src/com/intellij/openapi/progress/util/SuvorovProgress.kt index 3aacbc91dd52..51313b467852 100644 --- a/platform/platform-impl/src/com/intellij/openapi/progress/util/SuvorovProgress.kt +++ b/platform/platform-impl/src/com/intellij/openapi/progress/util/SuvorovProgress.kt @@ -3,13 +3,19 @@ package com.intellij.openapi.progress.util import com.intellij.CommonBundle import com.intellij.diagnostic.LoadingState +import com.intellij.diagnostic.PerformanceWatcher import com.intellij.ide.IdeEventQueue +import com.intellij.ide.actions.RevealFileAction import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.KeyboardShortcut +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.progress.impl.fus.FreezeUiUsageCollector +import com.intellij.openapi.progress.util.ui.NiceOverlayUi import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.registry.Registry import com.intellij.platform.locking.impl.getGlobalThreadingSupport +import com.intellij.ui.KeyStrokeAdapter import com.intellij.util.ui.AsyncProcessIcon import kotlinx.coroutines.Deferred import kotlinx.coroutines.InternalCoroutinesApi @@ -18,6 +24,8 @@ import org.jetbrains.annotations.ApiStatus import java.awt.AWTEvent import java.awt.KeyboardFocusManager import java.awt.event.InvocationEvent +import java.awt.event.KeyEvent +import java.awt.event.MouseEvent import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.atomic.AtomicReference import javax.swing.JFrame @@ -78,11 +86,57 @@ object SuvorovProgress { thisLogger().warn("Spinning progress would not work without enabled registry value `editor.allow.raw.access.on.edt`") processInvocationEventsWithoutDialog(awaitedValue, Int.MAX_VALUE) } + "NiceOverlay" -> showNiceOverlay(awaitedValue) "Bar", "Overlay" -> showPotemkinProgress(awaitedValue, isBar = value == "Bar") else -> throw IllegalArgumentException("Unknown value for registry key `ide.freeze.fake.progress.kind`: $value") } } + private fun showNiceOverlay(awaitedValue: Deferred<*>) { + val niceOverlay = NiceOverlayUi() + + val disposable = Disposer.newDisposable() + val stealer = PotemkinProgress.startStealingInputEvents( + { event -> + var dumpThreads = false + if (event is MouseEvent && event.id == MouseEvent.MOUSE_CLICKED) { + event.consume() + val reaction = niceOverlay.mouseClicked(event.point) + when (reaction) { + NiceOverlayUi.ClickOutcome.DUMP_THREADS -> dumpThreads = true + NiceOverlayUi.ClickOutcome.CLOSED, NiceOverlayUi.ClickOutcome.NOTHING -> Unit + } + } + if (event is MouseEvent && event.id == MouseEvent.MOUSE_MOVED) { + event.consume() + niceOverlay.mouseMoved(event.point) + } + if (event is KeyEvent && niceOverlay.dumpThreadsButtonShortcut == KeyStrokeAdapter.getDefaultKeyStroke(event)?.let { KeyboardShortcut(it, null) }) { + event.consume() + dumpThreads = true + } + if (dumpThreads) { + ApplicationManager.getApplication().executeOnPooledThread(Runnable { + val dumpDir = PerformanceWatcher.getInstance().dumpThreads("freeze-popup", true, false) + if (dumpDir != null) { + RevealFileAction.openFile(dumpDir) + } + }) + } + }, disposable) + + try { + while (!awaitedValue.isCompleted) { + niceOverlay.redrawMainComponent() + stealer.dispatchEvents(0) + Thread.sleep(10) + } + } + finally { + Disposer.dispose(disposable) + } + } + private fun showPotemkinProgress(awaitedValue: Deferred<*>, isBar: Boolean) { // some focus machinery may require Write-Intent read action // we need to remove it from there diff --git a/platform/platform-impl/src/com/intellij/openapi/progress/util/ui/NiceOverlayUi.kt b/platform/platform-impl/src/com/intellij/openapi/progress/util/ui/NiceOverlayUi.kt new file mode 100644 index 000000000000..ae3c94cd3aa4 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/openapi/progress/util/ui/NiceOverlayUi.kt @@ -0,0 +1,324 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.openapi.progress.util.ui + +import com.intellij.diagnostic.DiagnosticBundle +import com.intellij.openapi.actionSystem.KeyboardShortcut +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.keymap.KeymapUtil +import com.intellij.openapi.ui.impl.ShadowBorderPainter +import com.intellij.openapi.util.NlsSafe +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.wm.IdeFrame +import com.intellij.ui.ColorUtil +import com.intellij.ui.JBColor +import com.intellij.ui.JreHiDpiUtil +import com.intellij.ui.paint.PaintUtil +import com.intellij.ui.scale.JBUIScale +import com.intellij.util.ui.GraphicsUtil +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.annotations.Nls +import java.awt.* +import java.awt.event.InputEvent +import java.awt.event.KeyEvent +import java.awt.image.BufferedImage +import java.awt.image.MultiResolutionImage +import javax.swing.JRootPane +import javax.swing.KeyStroke +import javax.swing.SwingUtilities +import javax.swing.UIManager + +/** + * Implementation of Popup by UX-3484 + * We have to use AWT here, as we cannot spin the EventQueue for Swing + */ +@ApiStatus.Internal +class NiceOverlayUi { + private val rootPane: JRootPane = SwingUtilities.getRootPane(KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner) + + + private val mainText = DiagnosticBundle.message("freeze.popup.application.is.not.responding", ApplicationInfo.getInstance().versionName) + private val dumpThreadsButtonText = DiagnosticBundle.message("freeze.popup.dump.threads.suggestion") + val dumpThreadsButtonShortcut: KeyboardShortcut = KeyboardShortcut(KeyStroke.getKeyStroke(KeyEvent.VK_D, (if (SystemInfo.isMac) InputEvent.META_DOWN_MASK else InputEvent.CTRL_DOWN_MASK)), null) + private val dumpThreadsButtonShortcutText = KeymapUtil.getShortcutText(dumpThreadsButtonShortcut) + + + private val popupHeight: Int = JBUIScale.scale(32) + private val horizontalInset = JBUIScale.scale(12) + private val gapBetweenText1AndText2 = JBUIScale.scale(15) + private val gapBetweenText2AndText3 = JBUIScale.scale(3) + private val separatorInset = JBUIScale.scale(10) + private val closeIconLength = JBUIScale.scale(10) + private val border = JBUIScale.scale(8) + private val separatorHeight = JBUIScale.scale(20) + private val buttonMargin = JBUIScale.scale(4) + private val buttonHeight = JBUIScale.scale(20) + private val buttonArc = JBUIScale.scale(5) + private val font = JBUI.Fonts.label() + private val shadowSize: Int = JBUIScale.scale(3) + + + private val bgColor: Color = JBColor.namedColor("Notification.background") + private val borderColor: Color = JBColor.namedColor("Notification.borderColor") + private val fgColor: Color = ColorUtil.toAlpha(UIManager.getColor("Notification.foreground"), 220) + private val fgShortcutColor: Color = ColorUtil.toAlpha(fgColor, 100) + private val buttonHoverColor: Color = JBUI.CurrentTheme.ActionButton.pressedBackground() + + + private val currentFontMetrics: FontMetrics = GraphicsUtil.safelyGetGraphics(rootPane).getFontMetrics(font) + private val yOffsetOfTextInPopup: Int = popupHeight / 2 + currentFontMetrics.ascent / 2 - JBUIScale.scale(1) + + private val popupWidth: Int = horizontalInset + getTextLength(mainText) + getTextLength(dumpThreadsButtonText) + getTextLength(dumpThreadsButtonShortcutText) + gapBetweenText1AndText2 + gapBetweenText2AndText3 + separatorInset + 1 + separatorInset + closeIconLength + horizontalInset + + // offsets relative to the containing component + private val popupOffsetX: Int = rootPane.width / 2 - popupWidth / 2 + private val popupOffsetY: Int = run { + val frame = SwingUtilities.getAncestorOfClass(IdeFrame::class.java, rootPane) as? IdeFrame + frame?.statusBar?.component?.location?.y ?: (rootPane.height / 2 - popupHeight / 2) + } + + // regions of the buttons relative to the containing component + private val locationOfCross: Rectangle + private val locationOfThreadDumpButton: Rectangle + + /** + * The users might want to close the freeze popup. In this case, we need to draw the contents of whatever is behind the popup + * We cannot ask Swing to repaint the region, as we cannot exit the EDT event we are currently in. + * We cannot repaint the region manually, as the underlying painting logic might try to access the read/write lock, and we would get a deadlock. + * + * But we know that the UI is fronzen, hence we do a trick: we take a screenshot of the region where the freeze popup is located, + * and draw it back when the user decides to close the popup. + */ + private val screenshot: MultiResolutionImage + + /** + * The location of popup including its shadow; we need it to replace it with the screehshot later + */ + private val popupWithShadowLocation: Rectangle = Rectangle(popupOffsetX - shadowSize, popupOffsetY - shadowSize, popupWidth + shadowSize * 2, popupWidth + shadowSize * 2) + + /** + * The "model" of the popup + */ + private var closeButtonHovered: Boolean = false + private var threadDumpButtonHovered: Boolean = false + private var closed = false + + init { + screenshot = takeScreenshot() + + drawShadow() + + val startOfThreadDumpText = popupOffsetX + horizontalInset + getTextLength(mainText) + gapBetweenText1AndText2 + val offsetFromTop = popupOffsetY + (popupHeight - closeIconLength) / 2 - JBUIScale.scale(2) + locationOfThreadDumpButton = Rectangle( + startOfThreadDumpText - buttonMargin, + offsetFromTop - buttonMargin, + getTextLength(dumpThreadsButtonText) + gapBetweenText2AndText3 + getTextLength(dumpThreadsButtonShortcutText) + buttonMargin * 2, + buttonHeight) + locationOfCross = Rectangle( + startOfThreadDumpText + getTextLength(dumpThreadsButtonText) + gapBetweenText2AndText3 + getTextLength(dumpThreadsButtonShortcutText) + separatorInset + JBUIScale.scale(1) + separatorInset - buttonMargin, + offsetFromTop - buttonMargin, + closeIconLength + buttonMargin * 2, + buttonHeight) + + redrawMainComponent() + } + + private fun drawShadow() { + + val backBuffer = drawPopupContents() + + val originalGraphics = GraphicsUtil.safelyGetGraphics(rootPane) + try { + originalGraphics.translate(popupOffsetX, popupOffsetY) + val shadow = ShadowBorderPainter.createShadow(backBuffer, 0, 0, false, shadowSize) + + // unfortunately, our shadow-handling code is not scale-aware + if (JreHiDpiUtil.isJreHiDPI(originalGraphics as Graphics2D)) { + val scaledGraphics = originalGraphics.create() as Graphics2D + val s: Float = 1 / JBUIScale.sysScale(originalGraphics) + scaledGraphics.scale(s.toDouble(), s.toDouble()) + UIUtil.drawImage(scaledGraphics, shadow.getImage(), shadow.getX() + shadowSize + 1, shadow.getY() - shadowSize + 1, null) + scaledGraphics.dispose() + } + else { + UIUtil.drawImage(originalGraphics, shadow.image, shadow.x, shadow.y, null) + } + } + finally { + originalGraphics.dispose() + } + } + + /** + * Redraws the freeze popup based on its current state. + */ + fun redrawMainComponent() { + if (closed) { + return + } + + val popupImage = drawPopupContents() + + val originalGraphics = GraphicsUtil.safelyGetGraphics(rootPane) + try { + originalGraphics.translate(popupOffsetX, popupOffsetY) + UIUtil.drawImage(originalGraphics, popupImage, 0, 0, null) + } + finally { + originalGraphics.dispose() + } + } + + private fun takeScreenshot(): MultiResolutionImage { + val window = SwingUtilities.getWindowAncestor(rootPane) + return Robot().createMultiResolutionScreenCapture(Rectangle(window.x + popupWithShadowLocation.x, window.y + popupWithShadowLocation.y, popupWithShadowLocation.width, popupWithShadowLocation.height)) + } + + /** + * @param point the point relative to the containing component + */ + fun mouseMoved(point: Point) { + closeButtonHovered = locationOfCross.contains(point) + threadDumpButtonHovered = locationOfThreadDumpButton.contains(point) + } + + enum class ClickOutcome { + NOTHING, + DUMP_THREADS, + CLOSED, + } + + /** + * @param point the point relative to the containing component + */ + fun mouseClicked(point: Point): ClickOutcome { + if (locationOfCross.contains(point)) { + closed = true + restoreScreenshot() + return ClickOutcome.CLOSED + } + else if (locationOfThreadDumpButton.contains(point)) { + return ClickOutcome.DUMP_THREADS + } + else { + return ClickOutcome.NOTHING + } + } + + private fun restoreScreenshot() { + val innerGraphics = GraphicsUtil.safelyGetGraphics(rootPane) + try { + val variant = screenshot.getResolutionVariant(popupWithShadowLocation.width.toDouble() * JBUIScale.sysScale().toDouble(), popupWithShadowLocation.height.toDouble() * JBUIScale.sysScale().toDouble()) + if (JreHiDpiUtil.isJreHiDPI(innerGraphics as Graphics2D)) { + val scaledGraphics = innerGraphics.create() as Graphics2D + val s: Float = 1 / JBUIScale.sysScale(innerGraphics) + scaledGraphics.scale(s.toDouble(), s.toDouble()) + UIUtil.drawImage(scaledGraphics, variant, (popupWithShadowLocation.x * JBUIScale.sysScale().toDouble()).toInt(), (popupWithShadowLocation.y * JBUIScale.sysScale().toDouble()).toInt(), null) + scaledGraphics.dispose() + } + else { + UIUtil.drawImage(innerGraphics, variant, popupWithShadowLocation.x, popupWithShadowLocation.y, null) + } + } + finally { + innerGraphics.dispose() + } + } + + /** + * Computes a Hi-DPI image of the opaque part of the freeze popup. The image does not include shadow. + */ + private fun drawPopupContents(): BufferedImage { + + val backBuffer = UIUtil.createImage(rootPane.graphicsConfiguration, popupWidth.toDouble(), popupHeight.toDouble(), + BufferedImage.TYPE_INT_ARGB, PaintUtil.RoundingMode.ROUND) + val graphics: Graphics2D = backBuffer.createGraphics() + try { + GraphicsUtil.setupAAPainting(graphics) + graphics.font = font + + graphics.drawBackgroundShape() + graphics.drawBorder() + var accumulatingOffsetX = horizontalInset + + val lengthOfText1 = SwingUtilities.computeStringWidth(currentFontMetrics, mainText) + val lengthOfText2 = SwingUtilities.computeStringWidth(currentFontMetrics, dumpThreadsButtonText) + val lengthOfText3 = SwingUtilities.computeStringWidth(currentFontMetrics, dumpThreadsButtonShortcutText) + + graphics.drawMainText(accumulatingOffsetX, mainText) + + accumulatingOffsetX += lengthOfText1 + gapBetweenText1AndText2 + + if (threadDumpButtonHovered) { + graphics.paintButtonBackground(accumulatingOffsetX, lengthOfText2 + gapBetweenText2AndText3 + lengthOfText3) + } + + graphics.drawMainText(accumulatingOffsetX, dumpThreadsButtonText) + accumulatingOffsetX += lengthOfText2 + gapBetweenText2AndText3 + accumulatingOffsetX += graphics.drawShortcut(accumulatingOffsetX, dumpThreadsButtonShortcutText) + accumulatingOffsetX += graphics.drawSeparator(accumulatingOffsetX) + + graphics.color = fgColor + + if (closeButtonHovered) { + graphics.paintButtonBackground(accumulatingOffsetX, closeIconLength) + } + graphics.drawCloseButton(accumulatingOffsetX) + } + finally { + graphics.dispose() + } + + return backBuffer + } + + private fun getTextLength(text: @Nls String): Int { + return SwingUtilities.computeStringWidth(currentFontMetrics, text) + } + + private fun Graphics2D.drawBackgroundShape() { + color = bgColor + fillRoundRect(0, 0, popupWidth, popupHeight, border, border) + } + + private fun Graphics2D.drawBorder() { + stroke = BasicStroke(JBUIScale.scale(1.0f)) + color = borderColor + drawRoundRect(0, 0, popupWidth, popupHeight, border, border) + } + + private fun Graphics2D.drawMainText(startX: Int, text: @Nls String) { + color = fgColor + drawString(text, startX.toFloat(), yOffsetOfTextInPopup.toFloat()) + } + + private fun Graphics2D.drawShortcut(startX: Int, shortcut: @NlsSafe String): /* length of the drawn string */ Int { + color = fgShortcutColor + drawString(shortcut, startX, yOffsetOfTextInPopup) + return SwingUtilities.computeStringWidth(currentFontMetrics, shortcut) + } + + private fun Graphics2D.drawSeparator(startX: Int): /* length of separator including insets */ Int { + val actualStart = startX + separatorInset + color = ColorUtil.toAlpha(fgColor, 50) + stroke = BasicStroke(JBUIScale.scale(1.0f)) + val offsetFromTop = (popupHeight - separatorHeight) / 2 + drawLine(actualStart, offsetFromTop, actualStart, popupHeight - offsetFromTop) + return separatorInset + JBUIScale.scale(1) + separatorInset + } + + private fun Graphics2D.drawCloseButton(startX: Int) { + color = fgColor + val offsetFromTop = (popupHeight - closeIconLength) / 2 + drawLine(startX, offsetFromTop, startX + closeIconLength, popupHeight - offsetFromTop) + drawLine(startX + closeIconLength, offsetFromTop, startX, popupHeight - offsetFromTop) + } + + private fun Graphics2D.paintButtonBackground(startX: Int, lengthOfButton: Int) { + color = buttonHoverColor + fillRoundRect(startX - buttonMargin, (popupHeight - buttonHeight) / 2, lengthOfButton + buttonMargin * 2, buttonHeight, buttonArc, buttonArc) + } + +} \ No newline at end of file diff --git a/platform/util/resources/misc/registry.properties b/platform/util/resources/misc/registry.properties index 2da3b2460187..af8313aeaf74 100644 --- a/platform/util/resources/misc/registry.properties +++ b/platform/util/resources/misc/registry.properties @@ -323,7 +323,7 @@ ide.preferred.scrollable.viewport.visible.rows.description=Specifies default num ide.preferred.scrollable.viewport.extra.space=true ide.preferred.scrollable.viewport.extra.space.description=Adds an extra space to show that a list can be scrolled -ide.suvorov.progress.kind=[None|Bar|Spinning|Overlay*] +ide.suvorov.progress.kind=[None|Bar|Spinning|Overlay*|NiceOverlay] ide.suvorov.progress.kind.description=Kind of progress that is used to make an impression of live IDE when it is freezed on acquisition of locks ide.suvorov.progress.showing.delay.ms=1000