diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000000000..ee803c8a37f0c --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index dcee2a29c8769..6ed20576f2b83 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -800,6 +800,7 @@ + @@ -840,6 +841,7 @@ + diff --git a/.idea/runConfigurations/IDEA.xml b/.idea/runConfigurations/IDEA.xml index 18a927fe67401..eb651ad95d497 100644 --- a/.idea/runConfigurations/IDEA.xml +++ b/.idea/runConfigurations/IDEA.xml @@ -7,7 +7,7 @@ \ No newline at end of file diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt index 476dcb7a70901..41e197e4a750e 100644 --- a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt @@ -15,11 +15,13 @@ import javax.swing.JPanel import org.jetbrains.annotations.ApiStatus import org.jetbrains.jewel.bridge.actionSystem.ComponentDataProviderBridge import org.jetbrains.jewel.bridge.component.JBPopupRenderer +import org.jetbrains.jewel.bridge.icon.BridgeIconPainterProvider import org.jetbrains.jewel.bridge.theme.SwingBridgeTheme import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.foundation.LocalComponent as LocalComponentFoundation import org.jetbrains.jewel.foundation.util.JewelLogger import org.jetbrains.jewel.ui.component.LocalPopupRenderer +import org.jetbrains.jewel.ui.icon.LocalIconPainterProvider import org.jetbrains.jewel.ui.util.LocalMessageResourceResolverProvider /** @@ -52,6 +54,7 @@ public fun JewelComposePanel(config: ComposePanel.() -> Unit = {}, content: @Com CompositionLocalProvider( LocalComponentFoundation provides this@createJewelComposePanel, LocalPopupRenderer provides JBPopupRenderer, + LocalIconPainterProvider provides BridgeIconPainterProvider, ) { ComponentDataProviderBridge(jewelPanel, content = content) } diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/icon/BridgeJewelIconProvider.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/icon/BridgeJewelIconProvider.kt new file mode 100644 index 0000000000000..fe2f68e7c231f --- /dev/null +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/icon/BridgeJewelIconProvider.kt @@ -0,0 +1,35 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.bridge.icon + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter +import org.jetbrains.icons.api.DynamicIcon +import org.jetbrains.icons.api.Icon +import org.jetbrains.jewel.ui.icon.IconPainterProvider + +public object BridgeIconPainterProvider: IconPainterProvider { + @Composable + override fun getIconPainter(icon: Icon): Painter { + val iconState = if (icon is DynamicIcon) { + icon.onUpdate.collectAsState() + } else null + val painter = remember(icon, iconState) { + BridgeIconPainter(icon) + } + return painter + } +} + +private class BridgeIconPainter(val icon: Icon) : Painter() { + override val intrinsicSize: Size + get() = Size(32f, 32f) + + override fun DrawScope.onDraw() { + val api = ComposePaintingApi(this) + icon.render(api) + } +} diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/icon/ComposeBitmapImageResource.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/icon/ComposeBitmapImageResource.kt new file mode 100644 index 0000000000000..59f9e0cc8b5c2 --- /dev/null +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/icon/ComposeBitmapImageResource.kt @@ -0,0 +1,100 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.bridge.icon + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import org.jetbrains.icons.api.BitmapImageResource +import org.jetbrains.icons.api.Bounds +import org.jetbrains.icons.api.ImageResourceWithCrossApiCache +import org.jetbrains.icons.api.ImageScale +import org.jetbrains.icons.api.RescalableImageResource +import org.jetbrains.icons.api.cachedBitmap +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo + +public fun BitmapImageResource.composeBitmap(): ImageBitmap { + // TODO Check for compose image bitmap + val cache = (this as? ImageResourceWithCrossApiCache)?.crossApiCache + return cache?.cachedBitmap { + composeBitmapWithoutCaching().second + } ?: composeBitmapWithoutCaching().second +} + +public fun RescalableImageResource.composeBitmap(scale: ImageScale): ImageBitmap { + // TODO Check for compose image bitmap + val cache = (this as? ImageResourceWithCrossApiCache)?.crossApiCache + val cached = cache?.cachedBitmap { SingleBitmapCache() } + if (cached != null) { + return cached.getOrPut(this, scale) + } else { + return scale(scale).composeBitmap() + } +} + +private class SingleBitmapCache { + private var lastBitmap: CachedBitmap? = null + + private class CachedBitmap( + val dimensions: Bounds, + val composeBitmap: ImageBitmap, + val bitmap: Bitmap + ) + + fun getOrPut(image: RescalableImageResource, scale: ImageScale): ImageBitmap { + val last = lastBitmap + val expectedDimensions = image.calculateExpectedDimensions(scale) + if (last == null) { + return createNewBitmap(image, scale, expectedDimensions) + } else { + if (last.dimensions == expectedDimensions) { + return last.composeBitmap + } else if (last.dimensions.canFit(expectedDimensions)) { + last.bitmap.setPixelsFrom(image.scale(scale)) + return last.composeBitmap + } else { + return createNewBitmap(image, scale, expectedDimensions) + } + } + } + + private fun createNewBitmap(image: RescalableImageResource, imageScale: ImageScale, dimensions: Bounds): ImageBitmap { + val (skia, compose) = image.scale(imageScale).composeBitmapWithoutCaching() + val new = CachedBitmap( + dimensions, + compose, + skia + ) + lastBitmap = new + return new.composeBitmap + } +} + +private fun BitmapImageResource.composeBitmapWithoutCaching(): Pair { + val bitmap = Bitmap() + bitmap.allocPixels(ImageInfo.makeS32(width, height, ColorAlphaType.UNPREMUL)) + bitmap.setPixelsFrom(this) + return bitmap to bitmap.asComposeImageBitmap() +} + +private fun Bitmap.setPixelsFrom(image: BitmapImageResource) { + val bytesPerPixel = 4 + val pixels = ByteArray(width * height * bytesPerPixel) + + var k = 0 + for (y in 0 until height) { + for (x in 0 until width) { + val argb = image.getRGBOrNull(x, y) ?: 0 + val a = (argb shr 24) and 0xff + val r = (argb shr 16) and 0xff + val g = (argb shr 8) and 0xff + val b = (argb shr 0) and 0xff + pixels[k++] = b.toByte() + pixels[k++] = g.toByte() + pixels[k++] = r.toByte() + pixels[k++] = a.toByte() + } + } + + installPixels(pixels) +} diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/icon/ComposePaintingApi.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/icon/ComposePaintingApi.kt new file mode 100644 index 0000000000000..b4e48a5fb9ef5 --- /dev/null +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/icon/ComposePaintingApi.kt @@ -0,0 +1,58 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.bridge.icon + +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import org.jetbrains.icons.api.BitmapImageResource +import org.jetbrains.icons.api.Bounds +import org.jetbrains.icons.api.EmptyBitmapImageResource +import org.jetbrains.icons.api.FitAreaScale +import org.jetbrains.icons.api.PaintingApi +import org.jetbrains.icons.api.RescalableImageResource +import kotlin.math.roundToInt + +public class ComposePaintingApi( + public val drawScope: DrawScope +): PaintingApi { + override val bounds: Bounds = Bounds( + drawScope.size.width.roundToInt(), + drawScope.size.height.roundToInt() + ) + + override fun drawImage( + image: BitmapImageResource, + x: Int, + y: Int, + width: Int?, + height: Int?, + ) { + drawComposeImage(image.composeBitmap(), x, y, width, height) + } + + override fun drawImage(image: RescalableImageResource, x: Int, y: Int, width: Int?, height: Int?) { + drawComposeImage(image.composeBitmap(FitAreaScale(bounds.width, bounds.height)), x, y, width, height) + } + + private fun drawComposeImage( + image: ImageBitmap, + x: Int, + y: Int, + width: Int?, + height: Int?, + ) { + val targetSize = IntSize(width ?: image.width, height ?: image.height) + if (targetSize.width == 0 || targetSize.height == 0) return + drawScope.drawImage( + image, + IntOffset(x, y), + targetSize, + dstSize = targetSize, + alpha = 1.0f, + colorFilter = null, + filterQuality = FilterQuality.Low + ) + } +} diff --git a/platform/jewel/ui/BUILD.bazel b/platform/jewel/ui/BUILD.bazel index 21c4b2c61354a..f4ceb62aa627e 100644 --- a/platform/jewel/ui/BUILD.bazel +++ b/platform/jewel/ui/BUILD.bazel @@ -38,12 +38,14 @@ jvm_library( "@lib//:jetbrains-annotations", "//libraries/skiko", "//platform/jewel/foundation", + "//platform/jewel/icons", "@lib//:platform-jewel-ui-org-jetbrains-compose-components-components-resources", "@lib//:platform-jewel-ui-org-jetbrains-compose-components-components-resources-desktop", "//libraries/compose-foundation-desktop", ], exports = [ "//platform/jewel/foundation", + "//platform/jewel/icons", "@lib//:platform-jewel-ui-org-jetbrains-compose-components-components-resources", "@lib//:platform-jewel-ui-org-jetbrains-compose-components-components-resources-desktop", "//libraries/compose-foundation-desktop", @@ -65,6 +67,8 @@ jvm_library( "//libraries/skiko", "//platform/jewel/foundation", "//platform/jewel/foundation:foundation_test_lib", + "//platform/jewel/icons", + "//platform/jewel/icons:icons_test_lib", "@lib//:platform-jewel-ui-org-jetbrains-compose-components-components-resources", "@lib//:platform-jewel-ui-org-jetbrains-compose-components-components-resources-desktop", "//libraries/compose-foundation-desktop", @@ -73,6 +77,8 @@ jvm_library( exports = [ "//platform/jewel/foundation", "//platform/jewel/foundation:foundation_test_lib", + "//platform/jewel/icons", + "//platform/jewel/icons:icons_test_lib", "@lib//:platform-jewel-ui-org-jetbrains-compose-components-components-resources", "@lib//:platform-jewel-ui-org-jetbrains-compose-components-components-resources-desktop", "//libraries/compose-foundation-desktop", @@ -92,4 +98,4 @@ jps_test( name = "ui_test", runtime_deps = [":ui_test_lib"] ) -### auto-generated section `test intellij.platform.jewel.ui` end \ No newline at end of file +### auto-generated section `test intellij.platform.jewel.ui` end diff --git a/platform/jewel/ui/intellij.platform.jewel.ui.iml b/platform/jewel/ui/intellij.platform.jewel.ui.iml index 91af1636d5b87..d21a5f74defdd 100644 --- a/platform/jewel/ui/intellij.platform.jewel.ui.iml +++ b/platform/jewel/ui/intellij.platform.jewel.ui.iml @@ -74,6 +74,7 @@ + \ No newline at end of file diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt index 0355422a62f19..e6ca8042f5ff6 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Icon.kt @@ -6,6 +6,7 @@ package org.jetbrains.jewel.ui.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -27,13 +28,17 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.collect import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.decodeToImageBitmap import org.jetbrains.compose.resources.decodeToImageVector import org.jetbrains.compose.resources.decodeToSvgPainter +import org.jetbrains.icons.api.DynamicIcon +import org.jetbrains.icons.api.Icon import org.jetbrains.jewel.foundation.modifier.thenIf import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.icon.IconKey +import org.jetbrains.jewel.ui.icon.LocalIconPainterProvider import org.jetbrains.jewel.ui.icon.newUiChecker import org.jetbrains.jewel.ui.painter.PainterHint import org.jetbrains.jewel.ui.painter.rememberResourcePainterProvider @@ -81,6 +86,18 @@ public fun Icon( * @param hint [PainterHint] to be passed to the painter. */ @Suppress("ComposableParamOrder") // To fix in JEWEL-929 +@Composable +public fun Icon( + icon: Icon, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = Color.Unspecified, + vararg hints: PainterHint, +) { + val painter = LocalIconPainterProvider.current.getIconPainter(icon) + Icon(painter = painter, contentDescription = contentDescription, modifier = modifier, tint = tint) +} + @Composable public fun Icon( key: IconKey, @@ -298,8 +315,8 @@ private object ResourceLoader private fun readResourceBytes(resourcePath: String) = checkNotNull(ResourceLoader.javaClass.classLoader.getResourceAsStream(resourcePath)) { - "Could not load resource $resourcePath: it does not exist or can't be read." - } + "Could not load resource $resourcePath: it does not exist or can't be read." + } .readAllBytes() private fun Modifier.defaultSizeFor(painter: Painter) = diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconPainterProvider.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconPainterProvider.kt new file mode 100644 index 0000000000000..498d3ce4f7e63 --- /dev/null +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconPainterProvider.kt @@ -0,0 +1,18 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.ui.icon + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.painter.Painter +import org.jetbrains.icons.api.Icon + +public interface IconPainterProvider { + @Composable + public fun getIconPainter(icon: Icon): Painter +} + +public val LocalIconPainterProvider: ProvidableCompositionLocal = + staticCompositionLocalOf { + error("No LocalIconPainterProvider provided. Have you forgotten the theme?") + } diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconReference.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconReference.kt new file mode 100644 index 0000000000000..8b21d0dbc3a2a --- /dev/null +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconReference.kt @@ -0,0 +1,4 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.ui.icon + +public interface IconReference diff --git a/platform/platform-impl/bootstrap/src/com/intellij/platform/ide/bootstrap/ui.kt b/platform/platform-impl/bootstrap/src/com/intellij/platform/ide/bootstrap/ui.kt index aeed9fc0cbc30..800e0963df431 100644 --- a/platform/platform-impl/bootstrap/src/com/intellij/platform/ide/bootstrap/ui.kt +++ b/platform/platform-impl/bootstrap/src/com/intellij/platform/ide/bootstrap/ui.kt @@ -19,6 +19,7 @@ import com.intellij.openapi.wm.WeakFocusStackManager import com.intellij.platform.diagnostic.telemetry.impl.span import com.intellij.ui.AppUIUtil import com.intellij.ui.IconManager +import com.intellij.ui.icons.AwtImageResourceProviderImpl import com.intellij.ui.icons.CoreIconManager import com.intellij.ui.isWindowIconAlreadyExternallySet import com.intellij.ui.scale.JBUIScale @@ -28,6 +29,7 @@ import com.intellij.util.ui.StartupUiUtil import com.intellij.util.ui.accessibility.ScreenReader import kotlinx.coroutines.* import org.jetbrains.annotations.VisibleForTesting +import org.jetbrains.icons.api.ImageResourceProvider import java.awt.Font import java.awt.GraphicsEnvironment import java.awt.Toolkit @@ -44,6 +46,9 @@ internal suspend fun initUi(initAwtToolkitJob: Job, isHeadless: Boolean, asyncSc span("icon manager activation") { IconManager.activate(CoreIconManager()) } + span("image resource provider activation") { + ImageResourceProvider.activate(AwtImageResourceProviderImpl()) + } } initAwtToolkitJob.join() diff --git a/platform/platform-impl/intellij.platform.ide.impl.iml b/platform/platform-impl/intellij.platform.ide.impl.iml index 546cb9def9efd..604f5c9c8f2d5 100644 --- a/platform/platform-impl/intellij.platform.ide.impl.iml +++ b/platform/platform-impl/intellij.platform.ide.impl.iml @@ -171,5 +171,6 @@ + \ No newline at end of file diff --git a/platform/platform-impl/src/com/intellij/ui/DeferredIconImpl.kt b/platform/platform-impl/src/com/intellij/ui/DeferredIconImpl.kt index e9b02f84216ad..b4477c40a679e 100644 --- a/platform/platform-impl/src/com/intellij/ui/DeferredIconImpl.kt +++ b/platform/platform-impl/src/com/intellij/ui/DeferredIconImpl.kt @@ -19,9 +19,15 @@ import com.intellij.util.ui.EmptyIcon import com.intellij.util.ui.JBScalableIcon import com.intellij.util.ui.tree.TreeUtil import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.TestOnly import org.jetbrains.annotations.VisibleForTesting +import org.jetbrains.icons.api.DynamicIcon +import org.jetbrains.icons.api.DefaultIconState +import org.jetbrains.icons.api.IconIdentifier +import org.jetbrains.icons.api.PaintingApi +import org.jetbrains.icons.api.IconState import java.awt.Component import java.awt.Graphics import java.util.* @@ -33,7 +39,9 @@ import kotlin.coroutines.resume private val repaintScheduler = DeferredIconRepaintScheduler() -class DeferredIconImpl : JBScalableIcon, DeferredIcon, RetrievableIcon, IconWithToolTip, CopyableIcon { +object DeferredIconState : IconState + +class DeferredIconImpl : JBScalableIcon, DeferredIcon, RetrievableIcon, IconWithToolTip, CopyableIcon, DynamicIcon { companion object { internal val EMPTY_ICON: Icon by lazy { EmptyIcon.create(16).withIconPreScaled(false) } @@ -44,6 +52,11 @@ class DeferredIconImpl : JBScalableIcon, DeferredIcon, RetrievableIcon, IconW } } + override val onUpdate: MutableStateFlow = MutableStateFlow(DefaultIconState) + override val state: IconState + get() = onUpdate.value + override val identifier: IconIdentifier + private val delegateIcon: Icon @Volatile @@ -78,6 +91,7 @@ class DeferredIconImpl : JBScalableIcon, DeferredIcon, RetrievableIcon, IconW asyncEvaluator = icon.asyncEvaluator isScheduled.set(icon.isScheduled.get()) param = icon.param + identifier = icon.identifier isNeedReadAction = icon.isNeedReadAction isDone = icon.isDone evaluatedListener = icon.evaluatedListener @@ -90,6 +104,7 @@ class DeferredIconImpl : JBScalableIcon, DeferredIcon, RetrievableIcon, IconW evaluator: (T) -> Icon?, listener: ((DeferredIconImpl, Icon) -> Unit)?) { this.param = param + identifier = FallbackIconIdentifier(param as Any) delegateIcon = baseIcon ?: EMPTY_ICON scaledDelegateIcon = delegateIcon cachedScaledIcon = null @@ -106,6 +121,7 @@ class DeferredIconImpl : JBScalableIcon, DeferredIcon, RetrievableIcon, IconW @ApiStatus.Internal constructor(baseIcon: Icon?, param: T, asyncEvaluator: suspend (T) -> Icon, listener: ((DeferredIconImpl, Icon) -> Unit)?) { this.param = param + identifier = FallbackIconIdentifier(param as Any) delegateIcon = baseIcon ?: EMPTY_ICON scaledDelegateIcon = delegateIcon cachedScaledIcon = null @@ -151,6 +167,27 @@ class DeferredIconImpl : JBScalableIcon, DeferredIcon, RetrievableIcon, IconW } } + override fun render(api: PaintingApi) { + val scaledDelegateIcon = scaledDelegateIcon + val swing = api.swing() + if (!(scaledDelegateIcon is DeferredIconImpl<*> && scaledDelegateIcon.containsDeferredIconsRecursively(2))) { + //SOE protection + SwingIconImpl.renderOldIcon(scaledDelegateIcon, api) + } + else { + logger>().warn("Not painted, too many deferrals") + } + + // TODO Migrate repaint logic to DynamicIcon api + if (swing != null) { + if (needScheduleEvaluation()) { + scheduleEvaluation(swing.c, swing.x, swing.y) + } + } else { + scheduleCalculationIfNeeded() + } + } + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { val scaledDelegateIcon = scaledDelegateIcon if (!(scaledDelegateIcon is DeferredIconImpl<*> && scaledDelegateIcon.containsDeferredIconsRecursively(2))) { @@ -237,9 +274,10 @@ class DeferredIconImpl : JBScalableIcon, DeferredIcon, RetrievableIcon, IconW scaledDelegateIcon = result modificationCount.incrementAndGet() checkDelegationDepth() - evaluatedListener?.invoke(this@DeferredIconImpl, result) + onUpdate.emit(DeferredIconState) + processRepaints(oldWidth = oldWidth, result = result) setDone(result) } diff --git a/platform/platform-resources/src/META-INF/common-ide-modules.xml b/platform/platform-resources/src/META-INF/common-ide-modules.xml index 8f0891028cf1b..5d2a75caf96df 100644 --- a/platform/platform-resources/src/META-INF/common-ide-modules.xml +++ b/platform/platform-resources/src/META-INF/common-ide-modules.xml @@ -35,6 +35,7 @@ + diff --git a/platform/util/BUILD.bazel b/platform/util/BUILD.bazel index 4f2d01519c51f..cf7f61669baf6 100644 --- a/platform/util/BUILD.bazel +++ b/platform/util/BUILD.bazel @@ -38,6 +38,7 @@ jvm_library( "@lib//:kotlinx-serialization-json", "//platform/util/coroutines", "//platform/util/multiplatform", + "//platform/jewel/icons", ":platform_util_troveCompileOnly_provided", ], exports = [ @@ -45,6 +46,7 @@ jvm_library( "//platform/util-rt", "//platform/util/base", "//platform/util/multiplatform", + "//platform/jewel/icons", ], runtime_deps = [ ":util_resources", diff --git a/platform/util/intellij.platform.util.iml b/platform/util/intellij.platform.util.iml index 85fd8eceaa2d0..e05d646d24937 100644 --- a/platform/util/intellij.platform.util.iml +++ b/platform/util/intellij.platform.util.iml @@ -47,6 +47,7 @@ + diff --git a/platform/util/src/com/intellij/openapi/util/ScalableIcon.java b/platform/util/src/com/intellij/openapi/util/ScalableIcon.java index 8041023322c7e..b6dd586ea9230 100644 --- a/platform/util/src/com/intellij/openapi/util/ScalableIcon.java +++ b/platform/util/src/com/intellij/openapi/util/ScalableIcon.java @@ -3,7 +3,7 @@ import org.jetbrains.annotations.NotNull; -import javax.swing.*; +import javax.swing.Icon; /** * @author Konstantin Bulenkov diff --git a/platform/util/src/com/intellij/ui/icons/AwtImageResource.kt b/platform/util/src/com/intellij/ui/icons/AwtImageResource.kt new file mode 100644 index 0000000000000..d6ae63c8eb166 --- /dev/null +++ b/platform/util/src/com/intellij/ui/icons/AwtImageResource.kt @@ -0,0 +1,19 @@ +// 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.ui.icons + +import org.jetbrains.icons.api.BitmapImageResource +import org.jetbrains.icons.api.ImageResource +import org.jetbrains.icons.api.ImageResourceProvider + +interface AwtImageResource : BitmapImageResource { + val image: java.awt.Image +} + +interface AwtImageResourceProvider : ImageResourceProvider { + fun fromAwtImage(image: java.awt.Image): AwtImageResource +} + +fun ImageResource.Companion.fromAwtImage(image: java.awt.Image): AwtImageResource { + val provider = ImageResourceProvider.getInstance() as? AwtImageResourceProvider + return provider?.fromAwtImage(image) ?: error("Current ImageResource provider doesn't support AWT images: $provider") +} \ No newline at end of file diff --git a/platform/util/src/com/intellij/ui/icons/CompositeIcon.java b/platform/util/src/com/intellij/ui/icons/CompositeIcon.java index 17fdf7dd0c9c9..6f5498ed90d4a 100644 --- a/platform/util/src/com/intellij/ui/icons/CompositeIcon.java +++ b/platform/util/src/com/intellij/ui/icons/CompositeIcon.java @@ -3,6 +3,7 @@ import org.jetbrains.annotations.Nullable; +import javax.swing.Icon; import javax.swing.*; /** diff --git a/platform/util/src/com/intellij/ui/icons/DarkIconProvider.java b/platform/util/src/com/intellij/ui/icons/DarkIconProvider.java index 1ffefc8e71dfb..eac8862a157d8 100644 --- a/platform/util/src/com/intellij/ui/icons/DarkIconProvider.java +++ b/platform/util/src/com/intellij/ui/icons/DarkIconProvider.java @@ -3,6 +3,7 @@ import org.jetbrains.annotations.NotNull; +import javax.swing.Icon; import javax.swing.*; public interface DarkIconProvider { diff --git a/platform/util/src/com/intellij/ui/icons/IconReplacer.java b/platform/util/src/com/intellij/ui/icons/IconReplacer.java index 22a9a4bf0a354..59b08f4385240 100644 --- a/platform/util/src/com/intellij/ui/icons/IconReplacer.java +++ b/platform/util/src/com/intellij/ui/icons/IconReplacer.java @@ -3,6 +3,7 @@ import org.jetbrains.annotations.Contract; +import javax.swing.Icon; import javax.swing.*; public interface IconReplacer { diff --git a/platform/util/src/com/intellij/ui/icons/IntelliJIconManager.kt b/platform/util/src/com/intellij/ui/icons/IntelliJIconManager.kt new file mode 100644 index 0000000000000..46b6ba4e926cc --- /dev/null +++ b/platform/util/src/com/intellij/ui/icons/IntelliJIconManager.kt @@ -0,0 +1,10 @@ +// 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.ui.icons + +import org.jetbrains.icons.api.IconIdentifier +import org.jetbrains.icons.api.IconManager + +interface IntelliJIconManager: IconManager { + override fun loadIcon(id: IconIdentifier, path: String, aClass: Class<*>): SwingIcon? + fun registerIcon(id: IconIdentifier, icon: SwingIcon) +} \ No newline at end of file diff --git a/platform/util/src/com/intellij/ui/icons/ReplaceableIcon.java b/platform/util/src/com/intellij/ui/icons/ReplaceableIcon.java index 5ec89e610d1fa..4027e84edc59c 100644 --- a/platform/util/src/com/intellij/ui/icons/ReplaceableIcon.java +++ b/platform/util/src/com/intellij/ui/icons/ReplaceableIcon.java @@ -4,7 +4,7 @@ import com.intellij.openapi.diagnostic.Logger; import org.jetbrains.annotations.NotNull; -import javax.swing.*; +import javax.swing.Icon; public interface ReplaceableIcon extends Icon { default @NotNull Icon replaceBy(@NotNull IconReplacer replacer) { diff --git a/platform/util/src/com/intellij/ui/icons/RowIcon.java b/platform/util/src/com/intellij/ui/icons/RowIcon.java index 467f68805803b..3a58bd7234d55 100644 --- a/platform/util/src/com/intellij/ui/icons/RowIcon.java +++ b/platform/util/src/com/intellij/ui/icons/RowIcon.java @@ -4,7 +4,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Unmodifiable; -import javax.swing.*; +import javax.swing.Icon; import java.util.List; public interface RowIcon extends CompositeIcon, DarkIconProvider { diff --git a/platform/util/src/com/intellij/ui/icons/SwingIcon.kt b/platform/util/src/com/intellij/ui/icons/SwingIcon.kt new file mode 100644 index 0000000000000..dbd6d9655df62 --- /dev/null +++ b/platform/util/src/com/intellij/ui/icons/SwingIcon.kt @@ -0,0 +1,4 @@ +// 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.ui.icons + +interface SwingIcon : org.jetbrains.icons.api.Icon, javax.swing.Icon \ No newline at end of file diff --git a/platform/util/ui/src/com/intellij/ui/RetrievableIcon.java b/platform/util/ui/src/com/intellij/ui/RetrievableIcon.java index e860e9c00bfdd..485a106d4d980 100644 --- a/platform/util/ui/src/com/intellij/ui/RetrievableIcon.java +++ b/platform/util/ui/src/com/intellij/ui/RetrievableIcon.java @@ -5,7 +5,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -import javax.swing.*; +import javax.swing.Icon; /** * An icon wrapping and painting another icon. diff --git a/platform/util/ui/src/com/intellij/ui/SizedIcon.java b/platform/util/ui/src/com/intellij/ui/SizedIcon.java index 92c31a0c65654..34cad1cd472aa 100644 --- a/platform/util/ui/src/com/intellij/ui/SizedIcon.java +++ b/platform/util/ui/src/com/intellij/ui/SizedIcon.java @@ -10,7 +10,7 @@ import com.intellij.util.ui.JBCachingScalableIcon; import org.jetbrains.annotations.NotNull; -import javax.swing.*; +import javax.swing.Icon; import java.awt.*; import static java.lang.Math.ceil; diff --git a/platform/util/ui/src/com/intellij/ui/icons/AwtImageResourceImpl.kt b/platform/util/ui/src/com/intellij/ui/icons/AwtImageResourceImpl.kt new file mode 100644 index 0000000000000..680e81ea0f7e5 --- /dev/null +++ b/platform/util/ui/src/com/intellij/ui/icons/AwtImageResourceImpl.kt @@ -0,0 +1,35 @@ +// 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.ui.icons + +import org.jetbrains.icons.api.CrossApiImageBitmapCache +import org.jetbrains.icons.api.ImageResourceWithCrossApiCache +import java.awt.Image +import java.awt.image.BufferedImage + +class AwtImageResourceImpl(override val image: Image) : AwtImageResource, ImageResourceWithCrossApiCache { + override fun getRGB(x: Int, y: Int): Int { + if (image is BufferedImage) { + return image.getRGB(x, y) + } else error("Reading RGB values from non buffered image is not supported yet.") + } + + override fun getRGBOrNull(x: Int, y: Int): Int? { + if (x < 0 || y < 0 || x >= width || y >= height) { return null} + return getRGB(x, y) + } + + override val width: Int = image.getWidth(null) + override val height: Int = image.getHeight(null) + + override val crossApiCache: CrossApiImageBitmapCache = CrossApiImageBitmapCacheImpl() + + init { + crossApiCache.cachedBitmap(Image::class) { image } + } +} + +class AwtImageResourceProviderImpl : AwtImageResourceProvider { + override fun fromAwtImage(image: Image): AwtImageResource { + return AwtImageResourceImpl(image) + } +} \ No newline at end of file diff --git a/platform/util/ui/src/com/intellij/ui/icons/CachedImageIcon.kt b/platform/util/ui/src/com/intellij/ui/icons/CachedImageIcon.kt index cde45fb34e71a..aae2bdad832e0 100644 --- a/platform/util/ui/src/com/intellij/ui/icons/CachedImageIcon.kt +++ b/platform/util/ui/src/com/intellij/ui/icons/CachedImageIcon.kt @@ -24,6 +24,17 @@ import kotlinx.serialization.protobuf.ProtoBuf import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.ApiStatus.Internal import org.jetbrains.annotations.TestOnly +import org.jetbrains.icons.api.BitmapImageResource +import org.jetbrains.icons.api.Bounds +import org.jetbrains.icons.api.CrossApiImageBitmapCache +import org.jetbrains.icons.api.EmptyBitmapImageResource +import org.jetbrains.icons.api.IconIdentifier +import org.jetbrains.icons.api.IconState +import org.jetbrains.icons.api.ImageResource +import org.jetbrains.icons.api.ImageResourceWithCrossApiCache +import org.jetbrains.icons.api.ImageScale +import org.jetbrains.icons.api.PaintingApi +import org.jetbrains.icons.api.RescalableImageResource import java.awt.* import java.awt.image.BufferedImage import java.awt.image.ImageFilter @@ -58,6 +69,46 @@ internal val pathTransform: AtomicReference = AtomicReference( IconTransform(dark = false, patchers = arrayOf(DeprecatedDuplicatesIconPathPatcher()), filter = null) ) +class CachedImageIconState( + val imageFlags: Int, + val flags: Int +): IconState { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CachedImageIconState + + if (imageFlags != other.imageFlags) return false + if (flags != other.flags) return false + + return true + } + + override fun hashCode(): Int { + var result = imageFlags + result = 31 * result + flags + return result + } +} + +class CachedImageIconIdentifier( + val coords: Pair? +): IconIdentifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CachedImageIconIdentifier + + return coords == other.coords + } + + override fun hashCode(): Int { + return coords.hashCode() + } +} + @Internal @ApiStatus.NonExtendable open class CachedImageIcon private constructor( @@ -73,10 +124,15 @@ open class CachedImageIcon private constructor( // isDark is not defined in most cases, and we use a global state at the call moment. private val attributes: IconAttributes = IconAttributes(), private val iconCache: ScaledIconCache = ScaledIconCache(), -) : CopyableIcon, ScalableIcon, DarkIconProvider, IconPathProvider, IconWithToolTip { +) : CopyableIcon, ScalableIcon, DarkIconProvider, IconPathProvider, IconWithToolTip, SwingIconImpl() { private var pathTransformModCount = -1 private var loaderModCount = -1 + override val identifier: IconIdentifier = CachedImageIconIdentifier(getCoords()) + + override val state: IconState + get() = CachedImageIconState(imageFlags, attributes.flags) + override val originalPath: String? get() = originalLoader.path @@ -115,7 +171,60 @@ open class CachedImageIcon private constructor( override fun getToolTip(composite: Boolean): String? = toolTip?.get() - final override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + private val imageResource = CachedImageIconResource(this) + + override fun render(api: PaintingApi) { + val swing = api.swing() + + if (swing != null) { + legacyPaintIcon(swing.c, swing.g, swing.x, swing.y) + } else { + api.drawImage(imageResource, 0, 0) + } + } + + private class CachedImageIconResource( + val oldIcon: CachedImageIcon + ): ImageResource, RescalableImageResource, ImageResourceWithCrossApiCache { + private var cache: BitmapCache? = null + + override val crossApiCache: CrossApiImageBitmapCache = CrossApiImageBitmapCacheImpl() + + override fun calculateExpectedDimensions(scale: ImageScale): Bounds { + val width = oldIcon.iconWidth + val height = oldIcon.iconHeight + val factor = scale.calculateScalingFactorByOriginalDimensions(width) + return Bounds((width * factor).roundToInt(), (height * factor).roundToInt()) + } + + override fun scale(scale: ImageScale): BitmapImageResource { + if (cache?.isSame(scale, oldIcon.state) != true) { + val objScale = scale.calculateScalingFactorByOriginalDimensions(oldIcon.iconWidth) + val scaleContext = ScaleContext.of(arrayOf( + ScaleType.OBJ_SCALE.of(objScale), + ScaleType.USR_SCALE.of(1.0), + ScaleType.SYS_SCALE.of(1.0), + )) + val rawImage = oldIcon.resolveImage(scaleContext = scaleContext) + val awtImage = (rawImage as? JBHiDPIScaledImage)?.delegate ?: rawImage + if (awtImage == null) { return EmptyBitmapImageResource } + val imageResource = ImageResource.fromAwtImage(awtImage) + cache = BitmapCache(imageResource, scale, oldIcon.state) + } + return cache!!.image + } + + private class BitmapCache( + val image: BitmapImageResource, + val scale: ImageScale, + val state: IconState + ) { + fun isSame(scale: ImageScale, state: IconState): Boolean = + this.scale == scale && this.state == state + } + } + + private fun legacyPaintIcon(c: Component?, g: Graphics, x: Int, y: Int) { val gc = c?.graphicsConfiguration ?: (g as? Graphics2D)?.deviceConfiguration val graphicsScale = computeGraphicsScale(g, gc) @@ -415,7 +524,6 @@ open class CachedImageIcon private constructor( override fun hashCode(): Int = Objects.hash( localFilterSupplier, colorPatcher, toolTip, scaleContext, originalLoader, attributes.flags) - } @TestOnly diff --git a/platform/util/ui/src/com/intellij/ui/icons/CopyableIcon.java b/platform/util/ui/src/com/intellij/ui/icons/CopyableIcon.java index 3a9169f71e40e..957dc237936f9 100644 --- a/platform/util/ui/src/com/intellij/ui/icons/CopyableIcon.java +++ b/platform/util/ui/src/com/intellij/ui/icons/CopyableIcon.java @@ -3,6 +3,7 @@ import org.jetbrains.annotations.NotNull; +import javax.swing.Icon; import javax.swing.*; /** diff --git a/platform/util/ui/src/com/intellij/ui/icons/CrossApiImageBitmapCache.kt b/platform/util/ui/src/com/intellij/ui/icons/CrossApiImageBitmapCache.kt new file mode 100644 index 0000000000000..2cc00f5451a01 --- /dev/null +++ b/platform/util/ui/src/com/intellij/ui/icons/CrossApiImageBitmapCache.kt @@ -0,0 +1,18 @@ +// 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.ui.icons + +import org.jetbrains.icons.api.CrossApiImageBitmapCache +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap +import kotlin.reflect.KClass + +class CrossApiImageBitmapCacheImpl : CrossApiImageBitmapCache { + private val cache: ConcurrentMap, Any> = ConcurrentHashMap() + + override fun cachedBitmap(bitmapClass: KClass, generator: () -> TBitmap): TBitmap { + val bitmap = cache.computeIfAbsent(bitmapClass) { generator() } + if (bitmap == null || !bitmapClass.isInstance(bitmap)) error("Unexpected type of cached bitmap") + @Suppress("UNCHECKED_CAST") + return bitmap as TBitmap + } +} \ No newline at end of file diff --git a/platform/util/ui/src/com/intellij/ui/icons/FallbackIconIdentifier.kt b/platform/util/ui/src/com/intellij/ui/icons/FallbackIconIdentifier.kt new file mode 100644 index 0000000000000..dddd4806fa799 --- /dev/null +++ b/platform/util/ui/src/com/intellij/ui/icons/FallbackIconIdentifier.kt @@ -0,0 +1,21 @@ +// 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.ui.icons + +import org.jetbrains.icons.api.IconIdentifier + +class FallbackIconIdentifier( + val nestedIdentifier: Any +): IconIdentifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FallbackIconIdentifier + + return nestedIdentifier == other.nestedIdentifier + } + + override fun hashCode(): Int { + return nestedIdentifier.hashCode() + } +} \ No newline at end of file diff --git a/platform/util/ui/src/com/intellij/ui/icons/IconWithToolTip.java b/platform/util/ui/src/com/intellij/ui/icons/IconWithToolTip.java index ad4c1b0134455..d22538a96f726 100644 --- a/platform/util/ui/src/com/intellij/ui/icons/IconWithToolTip.java +++ b/platform/util/ui/src/com/intellij/ui/icons/IconWithToolTip.java @@ -3,7 +3,7 @@ import com.intellij.openapi.util.NlsSafe; import org.jetbrains.annotations.Nullable; - +import javax.swing.Icon; import javax.swing.*; /** diff --git a/platform/util/ui/src/com/intellij/ui/icons/IntelliJIconManager.kt b/platform/util/ui/src/com/intellij/ui/icons/IntelliJIconManager.kt new file mode 100644 index 0000000000000..34783a75353e8 --- /dev/null +++ b/platform/util/ui/src/com/intellij/ui/icons/IntelliJIconManager.kt @@ -0,0 +1,19 @@ +// 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.ui.icons + +import org.jetbrains.icons.api.Icon +import org.jetbrains.icons.api.IconIdentifier + +class IntelliJIconManagerImpl: IntelliJIconManager { + override fun loadIcon(id: IconIdentifier, path: String, aClass: Class<*>): SwingIcon? { + TODO("Not yet implemented") + } + + override fun registerIcon(id: IconIdentifier, icon: SwingIcon) { + + } + + override fun registerIcon(id: IconIdentifier, icon: Icon) { + + } +} \ No newline at end of file diff --git a/platform/util/ui/src/com/intellij/ui/icons/LazyImageIcon.java b/platform/util/ui/src/com/intellij/ui/icons/LazyImageIcon.java index d4a3f3c18e1dd..af4b943fc26ad 100644 --- a/platform/util/ui/src/com/intellij/ui/icons/LazyImageIcon.java +++ b/platform/util/ui/src/com/intellij/ui/icons/LazyImageIcon.java @@ -9,6 +9,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import javax.swing.Icon; import javax.swing.*; import java.awt.*; import java.lang.ref.Reference; diff --git a/platform/util/ui/src/com/intellij/ui/icons/MenuBarIconProvider.java b/platform/util/ui/src/com/intellij/ui/icons/MenuBarIconProvider.java index 7a7577a64b3d2..4650c74abe510 100644 --- a/platform/util/ui/src/com/intellij/ui/icons/MenuBarIconProvider.java +++ b/platform/util/ui/src/com/intellij/ui/icons/MenuBarIconProvider.java @@ -3,6 +3,7 @@ import org.jetbrains.annotations.NotNull; +import javax.swing.Icon; import javax.swing.*; // todo: remove and use DarkIconProvider when JBSDK supports scalable icons in menu diff --git a/platform/util/ui/src/com/intellij/ui/icons/SwingIconImpl.kt b/platform/util/ui/src/com/intellij/ui/icons/SwingIconImpl.kt new file mode 100644 index 0000000000000..7323232cbd53c --- /dev/null +++ b/platform/util/ui/src/com/intellij/ui/icons/SwingIconImpl.kt @@ -0,0 +1,25 @@ +// 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.ui.icons + +import org.jetbrains.icons.api.PaintingApi +import java.awt.Component +import java.awt.Graphics +import javax.swing.Icon + +abstract class SwingIconImpl : SwingIcon { + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + render(SwingPaintingApi(c, g, x, y)) + } + + companion object { + fun renderOldIcon(oldIcon: Icon, api: PaintingApi) { + val swing = api.swing() + if (oldIcon is org.jetbrains.icons.api.Icon) { + oldIcon.render(api) + } else if (swing != null) { + oldIcon.paintIcon(swing.c, swing.g, swing.x, swing.y) + } else error("Cannot render swing icon outside of swing.") + // TODO Support fallback painting (for example off-screen render and send bitmap) + } + } +} diff --git a/platform/util/ui/src/com/intellij/ui/icons/SwingPaintingApi.kt b/platform/util/ui/src/com/intellij/ui/icons/SwingPaintingApi.kt new file mode 100644 index 0000000000000..3922536edb161 --- /dev/null +++ b/platform/util/ui/src/com/intellij/ui/icons/SwingPaintingApi.kt @@ -0,0 +1,41 @@ +// 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.ui.icons + +import org.jetbrains.icons.api.BitmapImageResource +import org.jetbrains.icons.api.Bounds +import org.jetbrains.icons.api.FitAreaScale +import org.jetbrains.icons.api.PaintingApi +import org.jetbrains.icons.api.RescalableImageResource +import java.awt.Component +import java.awt.Graphics + +class SwingPaintingApi( + val c: Component?, + val g: Graphics, + val x: Int, + val y: Int +) : PaintingApi { + override val bounds: Bounds get() { + if (c == null) return Bounds(0, 0) + return Bounds( + c.width, + c.height + ) + } + + override fun drawImage(image: BitmapImageResource, x: Int, y: Int, width: Int?, height: Int?) { + val swingImage = image as? AwtImageResource ?: error("Image resource must be AwtImageResource to be rendered using swing.") + if (width != null && height != null) { + g.drawImage(swingImage.image, x, y, width, height, null) + } else { + g.drawImage(swingImage.image, x, y, null) + } + } + + override fun drawImage(image: RescalableImageResource, x: Int, y: Int, width: Int?, height: Int?) { + val swingImage = image.scale(FitAreaScale(width ?: bounds.width, height ?: bounds.height)) + drawImage(swingImage, x, y, width, height) + } +} + +fun PaintingApi.swing(): SwingPaintingApi? = this as? SwingPaintingApi \ No newline at end of file diff --git a/platform/util/ui/src/com/intellij/ui/icons/UpdatableIcon.java b/platform/util/ui/src/com/intellij/ui/icons/UpdatableIcon.java index 645f287b291c3..a82d1881e5bc7 100644 --- a/platform/util/ui/src/com/intellij/ui/icons/UpdatableIcon.java +++ b/platform/util/ui/src/com/intellij/ui/icons/UpdatableIcon.java @@ -4,6 +4,7 @@ import com.intellij.openapi.util.ModificationTracker; import org.jetbrains.annotations.NotNull; +import javax.swing.Icon; import javax.swing.*; import java.awt.*; diff --git a/platform/util/ui/src/com/intellij/util/ui/EmptyIcon.java b/platform/util/ui/src/com/intellij/util/ui/EmptyIcon.java index a471406a062b6..0956bccd3dd78 100644 --- a/platform/util/ui/src/com/intellij/util/ui/EmptyIcon.java +++ b/platform/util/ui/src/com/intellij/util/ui/EmptyIcon.java @@ -5,11 +5,16 @@ import com.intellij.ui.scale.DerivedScaleType; import com.intellij.ui.scale.JBUIScale; import org.jetbrains.annotations.NotNull; +import org.jetbrains.icons.api.DefaultIconState; +import org.jetbrains.icons.api.IconIdentifier; +import org.jetbrains.icons.api.IconState; +import org.jetbrains.icons.api.PaintingApi; import javax.swing.*; import javax.swing.plaf.UIResource; import java.awt.*; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; /** @@ -21,7 +26,7 @@ * * @see ColorIcon */ - public class EmptyIcon extends JBCachingScalableIcon { + public class EmptyIcon extends JBCachingScalableIcon implements org.jetbrains.icons.api.Icon { private static final Map, EmptyIcon> cache = new ConcurrentHashMap<>(); public static final Icon ICON_18 = JBUIScale.scaleIcon(create(18)); @@ -38,6 +43,16 @@ public class EmptyIcon extends JBCachingScalableIcon { JBUIScale.addUserScaleChangeListener(event -> cache.clear()); } + @Override + public @NotNull IconIdentifier getIdentifier() { + return new EmptyIconIdentifier(width, height); + } + + @Override + public @NotNull IconState getState() { + return DefaultIconState.INSTANCE; + } + /** * Creates an icon of the provided size. *

@@ -125,6 +140,10 @@ public int getIconHeight() { public void paintIcon(Component component, Graphics g, int i, int j) { } + @Override + public void render(@NotNull PaintingApi api) { + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -158,3 +177,25 @@ public static final class EmptyIconUIResource extends EmptyIcon implements UIRes } } } + +class EmptyIconIdentifier implements IconIdentifier { + int width; + int height; + + EmptyIconIdentifier(int width, int height) { + this.width = width; + this.height = height; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + EmptyIconIdentifier that = (EmptyIconIdentifier)o; + return width == that.width && height == that.height; + } + + @Override + public int hashCode() { + return Objects.hash(width, height); + } +} \ No newline at end of file diff --git a/plugins/devkit/intellij.devkit.compose/src/demo/ComponentShowcaseTab.kt b/plugins/devkit/intellij.devkit.compose/src/demo/ComponentShowcaseTab.kt index acfbe489da0d4..f4ac5f537b665 100644 --- a/plugins/devkit/intellij.devkit.compose/src/demo/ComponentShowcaseTab.kt +++ b/plugins/devkit/intellij.devkit.compose/src/demo/ComponentShowcaseTab.kt @@ -13,10 +13,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.intellij.icons.AllIcons import com.intellij.ide.BrowserUtil import com.intellij.openapi.project.Project +import com.intellij.ui.IconDeferrer import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI +import kotlinx.coroutines.delay +import org.jetbrains.icons.api.Icon import org.jetbrains.jewel.bridge.toComposeColor import org.jetbrains.jewel.foundation.LocalComponent import org.jetbrains.jewel.foundation.actionSystem.provideData @@ -284,7 +288,7 @@ private fun IconsShowcase() { } IconButton(onClick = {}, Modifier.size(24.dp)) { - Icon(key = AllIconsKeys.Actions.Close, contentDescription = "Close") + Icon(AllIconsKeys.Actions.Close, "Close") } IconActionButton( @@ -295,9 +299,25 @@ private fun IconsShowcase() { hints = arrayOf(Size(24)), tooltip = { Text("Hello there") }, ) + + Box { + Icon(AllIcons.General.OpenDisk as Icon, "Build Load Changes") + } + + Box { + Icon(deferedIcon as Icon, "Deferred Icon Sample") + } } } +private val deferedIcon = IconDeferrer.getInstance().deferAsync( + AllIcons.General.Print, + "KABOOM-DEF_ICON_TST" +) { + delay(10000) + AllIcons.General.GreenCheckmark +} + @Composable private fun RowScope.ColumnTwo(project: Project) { Column(Modifier.trackActivation().weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) { diff --git a/plugins/devkit/intellij.devkit.compose/src/showcase/ComposePerformanceDemoAction.kt b/plugins/devkit/intellij.devkit.compose/src/showcase/ComposePerformanceDemoAction.kt index 48145337aea3a..28372645314ac 100644 --- a/plugins/devkit/intellij.devkit.compose/src/showcase/ComposePerformanceDemoAction.kt +++ b/plugins/devkit/intellij.devkit.compose/src/showcase/ComposePerformanceDemoAction.kt @@ -3,13 +3,17 @@ package com.intellij.devkit.compose.showcase import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color @@ -21,18 +25,24 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper import org.jetbrains.jewel.bridge.compose +import org.jetbrains.jewel.ui.component.Checkbox +import org.jetbrains.jewel.ui.component.Icon import org.jetbrains.jewel.ui.component.Slider import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icon.IconKey +import org.jetbrains.jewel.ui.icons.AllIconsKeys import java.awt.BorderLayout import java.util.* import javax.swing.* import kotlin.math.* +import kotlin.reflect.typeOf internal class ComposePerformanceDemoAction : DumbAwareAction() { @@ -49,7 +59,7 @@ private class MyDialog(project: Project?, dialogTitle: String) : DialogWrapper(project, null, true, IdeModalityType.MODELESS, true) { val centerPanelWrapper = JPanel(BorderLayout()) - enum class TestCase { TextAnimation, Canvas } + enum class TestCase { TextAnimation, Canvas, Icons } enum class Mode { Swing, AWT } var mode = Mode.Swing @@ -74,17 +84,22 @@ private class MyDialog(project: Project?, dialogTitle: String) : ButtonGroup().let { group -> val textAnimationButton = JRadioButton("Text animation") val canvasButton = JRadioButton("Canvas") + val iconsButton = JRadioButton("Icons") group.add(textAnimationButton) group.add(canvasButton) + group.add(iconsButton) textAnimationButton.isSelected = testCase == TestCase.TextAnimation canvasButton.isSelected = testCase == TestCase.Canvas + iconsButton.isSelected = testCase == TestCase.Icons textAnimationButton.addActionListener { testCase = TestCase.TextAnimation; initCentralPanel() } canvasButton.addActionListener { testCase = TestCase.Canvas; initCentralPanel() } + iconsButton.addActionListener { testCase = TestCase.Icons; initCentralPanel() } controlPanel.add(canvasButton) controlPanel.add(textAnimationButton) + controlPanel.add(iconsButton) } controlPanel.add(JSeparator(JSeparator.VERTICAL)) @@ -119,6 +134,7 @@ private class MyDialog(project: Project?, dialogTitle: String) : val comp = when (testCase) { TestCase.TextAnimation -> createTextAnimationComponent() TestCase.Canvas -> createClockComponent() + TestCase.Icons -> createIconsComponent() } centerPanelWrapper.add(comp) @@ -129,6 +145,111 @@ private class MyDialog(project: Project?, dialogTitle: String) : } } +private fun createIconsComponent(): JComponent { + return compose { + var minFps by remember { mutableStateOf(Int.MAX_VALUE) } + var maxFps by remember { mutableStateOf(Int.MIN_VALUE) } + val frameTimes = remember { LinkedList() } + SideEffect { + frameTimes.add(System.nanoTime()) + frameTimes.removeAll { it < System.nanoTime() - 1_000_000_000 } + } + + val transition = rememberInfiniteTransition("coso") + val iconSize by transition.animateFloat( + 30f, + 50f, + infiniteRepeatable(tween(durationMillis = 1000, easing = EaseInOut), repeatMode = RepeatMode.Reverse), + ) + + Column { + val fps = frameTimes.size + if (fps > maxFps) { + maxFps = fps + } + if (fps < minFps) { + minFps = fps + } + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Text( + text = "FPS: $fps", + modifier = Modifier.padding(10.dp), + fontSize = 25.sp, + color = Color.Red + ) + Text( + text = "MIN: $minFps", + modifier = Modifier.padding(10.dp), + fontSize = 25.sp, + color = Color.Red + ) + Text( + text = "MAX: $maxFps", + modifier = Modifier.padding(10.dp), + fontSize = 25.sp, + color = Color.Red + ) + } + + var useComposeIcons by remember { mutableStateOf(false) } + Row { + Text("Use Compose Icons: ") + Checkbox(useComposeIcons, { + maxFps = Int.MIN_VALUE + minFps = Int.MAX_VALUE + useComposeIcons = it + }) + } + + Column(modifier = Modifier.fillMaxSize().clipToBounds(), verticalArrangement = Arrangement.Center) { + if (useComposeIcons) { + val icons = remember { + val icons = mutableListOf() + for (nested in AllIconsKeys::class.java.nestMembers) { + val fields = nested.declaredFields.filter { it.type.name.contains("IconKey") } + icons.addAll(fields.map { it.get(null) as IconKey }) + } + icons + } + + var i = 1 + for (x in 0..30) { + Row { + for (y in 0..30) { + Column(modifier = Modifier.size(55.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + val icon = icons[i++ % icons.size] + Icon(icon, "Balloon", modifier = Modifier.size(iconSize.dp).padding(10.dp)) + } + } + } + } + } else { + val icons = remember { + val icons = mutableListOf() + for (nested in AllIcons::class.java.nestMembers) { + val fields = nested.declaredFields.filter { it.type.name.contains("Icon") } + icons.addAll(fields.map { it.get(null) as org.jetbrains.icons.api.Icon }) + } + icons + } + + var i = 1 + for (x in 0..30) { + Row { + for (y in 0..30) { + Column(modifier = Modifier.size(55.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + val icon = icons[i++ % icons.size] + Icon(icon, "Balloon", modifier = Modifier.size(iconSize.dp).padding(10.dp)) + } + } + } + } + } + } + } + } +} + private fun createClockComponent(): JComponent { return compose { val mode = if (System.getProperty("compose.swing.render.on.graphics", "false").toBoolean()) "Swing" else "AWT" diff --git a/plugins/devkit/intellij.devkit.compose/src/showcase/ComposeShowcase.kt b/plugins/devkit/intellij.devkit.compose/src/showcase/ComposeShowcase.kt index 516312fa006d3..f06afa7f53305 100644 --- a/plugins/devkit/intellij.devkit.compose/src/showcase/ComposeShowcase.kt +++ b/plugins/devkit/intellij.devkit.compose/src/showcase/ComposeShowcase.kt @@ -47,14 +47,19 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.intellij.icons.AllIcons +import com.intellij.ide.ui.NotPatchedIconRegistry +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileChooser.FileChooser import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.keymap.KeymapUtil +import com.intellij.ui.IconDeferrer import com.intellij.ui.UIBundle import com.intellij.util.ui.JBUI import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import org.jetbrains.icons.api.Icon import org.jetbrains.jewel.bridge.toComposeColor import org.jetbrains.jewel.foundation.modifier.onHover import org.jetbrains.jewel.foundation.theme.JewelTheme @@ -128,6 +133,27 @@ private fun InfiniteAnimation() { Box(Modifier.alpha(animatedAlpha)) { Text("Animation!") } + + val iconSize by transition.animateFloat( + 0f, + 250f, + infiniteRepeatable(tween(durationMillis = 1000, easing = EaseInOut), repeatMode = RepeatMode.Reverse), + ) + Box { + Icon(AllIcons.General.OpenDisk as Icon, "Build Load Changes", modifier = Modifier.size(iconSize.dp)) + } + + Box { + Icon(deferedIcon as Icon, "Deferred Icon Sample") + } +} + +private val deferedIcon = IconDeferrer.getInstance().deferAsync( + AllIcons.General.Print, + "KABOOM-DEF_ICON_TST" +) { + delay(10000) + AllIcons.General.GreenCheckmark } @Composable