[threading] UX-3484 IJPL-182690: Implement new design for Suvorov Progress

GitOrigin-RevId: f2e38ab95049656d2460909bf5f6470cf807d7c2
This commit is contained in:
Konstantin Nisht
2025-06-17 20:02:39 +00:00
committed by intellij-monorepo-bot
parent 7152e8d951
commit 568a117bc7
4 changed files with 382 additions and 1 deletions
@@ -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=<html>To avoid performance issues, exclude the IDE and the project folders from the Real-Time Protection:\
<br>{0}<br>\
@@ -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
@@ -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)
}
}
@@ -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