mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
[threading] UX-3484 IJPL-182690: Implement new design for Suvorov Progress
GitOrigin-RevId: f2e38ab95049656d2460909bf5f6470cf807d7c2
This commit is contained in:
committed by
intellij-monorepo-bot
parent
7152e8d951
commit
568a117bc7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user