diff --git a/.idea/modules.xml b/.idea/modules.xml index fde07b719fc3e..64e00b6209255 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -1191,6 +1191,11 @@ + + + + + diff --git a/build/bazel-generated-file-list.txt b/build/bazel-generated-file-list.txt index 319b6d8bd64e2..8d4c5b4553126 100644 --- a/build/bazel-generated-file-list.txt +++ b/build/bazel-generated-file-list.txt @@ -620,6 +620,11 @@ platform/find/backend platform/foldings platform/forms_rt platform/icons +platform/icons-api +platform/icons-api/rendering +platform/icons-impl +platform/icons-impl/intellij +platform/icons-impl/intellij/tests platform/ide-core platform/ide-core-impl platform/ide-core/plugins diff --git a/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/CoreModuleSets.kt b/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/CoreModuleSets.kt index 2b77bae1347ae..e64dc88945ea3 100644 --- a/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/CoreModuleSets.kt +++ b/platform/build-scripts/src/org/jetbrains/intellij/build/productLayout/CoreModuleSets.kt @@ -253,6 +253,8 @@ object CoreModuleSets { embeddedModule("intellij.platform.util.ui") embeddedModule("intellij.platform.util.coroutines") + embeddedModule("intellij.platform.icons.impl.intellij") + embeddedModule("intellij.platform.locking.impl") embeddedModule("intellij.platform.core") diff --git a/platform/core-ui/BUILD.bazel b/platform/core-ui/BUILD.bazel index 1f8740850206a..9188b37facaaa 100644 --- a/platform/core-ui/BUILD.bazel +++ b/platform/core-ui/BUILD.bazel @@ -19,6 +19,7 @@ jvm_library( "//platform/util:util-ui", "@lib//:kotlin-stdlib", "//libraries/hash4j", + "//platform/icons-api", ] ) @@ -33,6 +34,7 @@ jvm_library( "//platform/core-api:core_test_lib", "//platform/util:util-ui_test_lib", "//libraries/hash4j:hash4j_test_lib", + "//platform/icons-api:icons-api_test_lib", ] ) ### auto-generated section `build intellij.platform.core.ui` end \ No newline at end of file diff --git a/platform/core-ui/intellij.platform.core.ui.iml b/platform/core-ui/intellij.platform.core.ui.iml index de297648caaaa..994b900300812 100644 --- a/platform/core-ui/intellij.platform.core.ui.iml +++ b/platform/core-ui/intellij.platform.core.ui.iml @@ -13,5 +13,6 @@ + \ No newline at end of file diff --git a/platform/core-ui/module-content.yaml b/platform/core-ui/module-content.yaml index c07b56e8df3f1..b66af348e1874 100644 --- a/platform/core-ui/module-content.yaml +++ b/platform/core-ui/module-content.yaml @@ -1,3 +1,4 @@ - name: dist.all/lib/intellij.platform.core.ui.jar modules: - - name: intellij.platform.core.ui \ No newline at end of file + - name: intellij.platform.core.ui + - name: intellij.platform.icons.api \ No newline at end of file diff --git a/platform/icons-api/.gitignore b/platform/icons-api/.gitignore new file mode 100644 index 0000000000000..af272e31f1de3 --- /dev/null +++ b/platform/icons-api/.gitignore @@ -0,0 +1,111 @@ +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +### Gradle template +.gradle +build/ + +### Terraform template +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +*.ipr +*.iws +.idea/* +out/ +local.properties + +# IDEA/Android Studio project settings ignore exceptions +!.idea/codeStyles/ +!.idea/copyright/ +!.idea/dataSources.xml +!.idea/detekt.xml +!.idea/encodings.xml +!.idea/externalDependencies.xml +!.idea/fileTemplates/ +!.idea/icon.svg +!.idea/icon.png +!.idea/icon_dark.png +!.idea/inspectionProfiles/ +!.idea/ktfmt.xml +!.idea/ktlint.xml +!.idea/ktlint-plugin.xml +!.idea/runConfigurations/ +!.idea/scopes/ +!.idea/vcs.xml + +### Kotlin template +# Compiled class file +*.class + +# Log file +*.log + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +### Windows template +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### Misc + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Ignore IJP temp folder +/.intellijPlatform + +# Ignore release patch generator output +/this-release.txt + +# Ignore Kotlin compiler sessions +/.kotlin +/buildSrc/.kotlin +/foundation/bin/* +/samples/showcase/bin/* +/new_release_notes.md diff --git a/platform/icons-api/BUILD.bazel b/platform/icons-api/BUILD.bazel new file mode 100644 index 0000000000000..311cfd3baf8dc --- /dev/null +++ b/platform/icons-api/BUILD.bazel @@ -0,0 +1,28 @@ +### auto-generated section `build intellij.platform.icons.api` start +load("@rules_jvm//:jvm.bzl", "jvm_library") + +jvm_library( + name = "icons-api", + module_name = "intellij.platform.icons.api", + visibility = ["//visibility:public"], + srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True), + deps = [ + "@lib//:kotlin-stdlib", + "//libraries/kotlinx/coroutines/core", + "//libraries/kotlinx/serialization/core", + "@lib//:jetbrains-annotations", + ] +) + +jvm_library( + name = "icons-api_test_lib", + module_name = "intellij.platform.icons.api", + visibility = ["//visibility:public"], + srcs = glob([], allow_empty = True), + runtime_deps = [ + ":icons-api", + "//libraries/kotlinx/coroutines/core:core_test_lib", + "//libraries/kotlinx/serialization/core:core_test_lib", + ] +) +### auto-generated section `build intellij.platform.icons.api` end \ No newline at end of file diff --git a/platform/icons-api/README.md b/platform/icons-api/README.md new file mode 100644 index 0000000000000..7520bb5587c77 --- /dev/null +++ b/platform/icons-api/README.md @@ -0,0 +1,97 @@ +# Cross frontend-api Icons +These Icons support being rendered by multiple frontend-apis (swing, compose, can be extended) + +## Data & Rendering split +The API is split to two parts: +- [Data](.) +- [Rendering](./rendering) + +This allows declaring icons without depending on the rendering implementation, which is useful on the backend. + +## `org.jetbrains.icons.api.Icon` +This is data part, an Icon, which represents how the specific Icon should look like. +This should be serializable (atleast in the IntelliJ implementation) and sendable directly via RPC. +Creating this class should not generate any side-effects or long-loading of resources. + +```kotlin +val githubIcon = icon { + image("icons/github.svg", ShowcaseIcons::class.java.classLoader, modifier = IconModifier.fillMaxSize()) +} +``` + +Layouting is also supported, where you can layer more icons into one: +```kotlin +val githubIcon = imageIcon("icons/github.svg", javaClass.classLoader) +val gitIcon = imageIcon("icons/git.svg", javaClass.classLoader) + +val layeredIcon = icon { + icon(githubIcon) + icon(gitIcon) +} + +val rowLayeredIcon = icon { + column { + row { + icon(githubIcon) + icon(gitIcon) + } + row { + icon(githubIcon) + icon(gitIcon) + } + } +} +``` +Row & Column behaves similarly to Compose counterparts. +There is also IconModifiers, that can affect how the resulting Icon will look like, +again, concept from Compose, you can use modifiers to adjust: +- Layouting (margin, size, align +- Color filtering +- Svg replacements + +The Icons are going to infer expected size (based on settings, svg data or image data), +however, you can also make the icons scaled to the container component. + +To serialize an icon, serializers module can be obtained from the IconManager. + +## `org.jetbrains.icons.api.rendering.IconRenderer` +While having data object for Icon is great, we still need a way to render it. +This class is responsible for getting the previously mentioned Icon data object, +it figures out how to load the used images. +Creating this class actually loads resources, so it should be considered a heavy operation. + +This normally shouldn't be used directly, but rather via components, like the Jewel `fun Icon(..)` icon, or specific swing components, +that create the renderer themselves. +To create a renderer for an Icon, you should use Icon.createRenderer() function. + +The final size of the Icon is inferred from the Icon data object but also affected by the scale factor and the containing component. +In compose, the size is affected by modifiers applied to the `Icon()` composable, the swing conunterpart can be configured via the +toSwingIcon() function. (or will be in the future) + +## Legacy/Swing Icon interop +To use Swing icons inside the new icons/with the new api, check [legacy-icon-support](./legacy-icon-support/) + +## `org.jetbrains.icons.api.IconManager` +This interface is responsible for generating Icon models. + +## Extensibility +All extensions should be registered beforehand, to allow deserialization, where we need to know +all possible layers, loaders, etc. to properly deserialize icons. That is why it is not enough to +just pass your loaders/layers to the IconManager or whatever when creating the icon. + +### Custom Layer +Custom layers can be easily added by designer's custom function. However, make sure to also register +the layer in the IconManager, to make sure it is serializable, also, ensure to register corresponding Icon Layer Renderer. + +### ImageResourceLoader +Custom image resource loaders can be added. +To do so, you need to implement `org.jetbrains.icons.api.ImageResourceLoader` interface and +then register it via the appropriate IconManager, check individual managers for details. +The registration will tell the IconManager how to serialize this loader and will let +ImageResourceProvider know how to load the resources. +Make sure to implement equals and hashCode functions, as they are used for caching purposes. + +Also, introduce an extension function for IconDesigner to allow easy creation of your new loader. + +## Implementation details +For implementation details, refer to [implementation modules](../icons-impl/README.md) \ No newline at end of file diff --git a/platform/icons-api/STATE.md b/platform/icons-api/STATE.md new file mode 100644 index 0000000000000..53d6148d87ee9 --- /dev/null +++ b/platform/icons-api/STATE.md @@ -0,0 +1,53 @@ + +# Icons API state + +* πŸ’» in progress +* βœ… works everywhere +* ❌ not done +* 🚢 works in compose +* πŸ‘« works in ij-compose +* 🐌 works in swing +* ❔ unsure if planned +* πŸ”§ partially done + +## Layers + +* image βœ… +* icon βœ… +* row βœ… +* column βœ… +* animation βœ… +* swingIcon βœ… +* text ❌ +* badge πŸ’» + +## Modifiers + +* align βœ… +* alpha 🚢 +* color filter 🚢 +* size (height/width) βœ… +* margin βœ… +* svg patcher πŸ‘«πŸŒ – uses legacy api patching + * filters ❌ +* stroke πŸ’» + +# Deferred Icons + +* local βœ… +* over network ❌ + +## Implementation + +* caching πŸ”§ – using legacy api atm. +* loading πŸ”§ – using legacy api atm. +* skiko svg rendering❔ +* intrinsic size calculations πŸšΆπŸ‘« +* scaling πŸšΆπŸ‘« +* blend modes πŸ”§ – only some are supported +* update/re-render dispatching πŸšΆπŸ‘« + +## Loading options +- block βœ… +- blank ❌ +- placeholder ❌ \ No newline at end of file diff --git a/platform/icons-api/build.gradle.kts b/platform/icons-api/build.gradle.kts new file mode 100644 index 0000000000000..ad95c34646677 --- /dev/null +++ b/platform/icons-api/build.gradle.kts @@ -0,0 +1,26 @@ +// This file is used by Jewel gradle script, check community/platform/jewel + +plugins { + jewel + alias(libs.plugins.kotlinx.serialization) +} + +sourceSets { + main { + kotlin { + setSrcDirs(listOf("src")) + } + } + + test { + kotlin { + setSrcDirs(listOf("test")) + } + } +} + +dependencies { + api("org.jetbrains:annotations:26.0.2") + api(libs.kotlinx.serialization.core) + api(libs.kotlinx.coroutines.core) +} diff --git a/platform/icons-api/intellij.platform.icons.api.iml b/platform/icons-api/intellij.platform.icons.api.iml new file mode 100644 index 0000000000000..7499edef5be6b --- /dev/null +++ b/platform/icons-api/intellij.platform.icons.api.iml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + $KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/platform/icons-api/rendering/BUILD.bazel b/platform/icons-api/rendering/BUILD.bazel new file mode 100644 index 0000000000000..6f84826b43e4d --- /dev/null +++ b/platform/icons-api/rendering/BUILD.bazel @@ -0,0 +1,33 @@ +### auto-generated section `build intellij.platform.icons.api.rendering` start +load("@rules_jvm//:jvm.bzl", "jvm_library") + +jvm_library( + name = "rendering", + module_name = "intellij.platform.icons.api.rendering", + visibility = ["//visibility:public"], + srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True), + deps = [ + "@lib//:kotlin-stdlib", + "//libraries/kotlinx/coroutines/core", + "//platform/icons-api", + "@lib//:jetbrains-annotations", + ], + exports = ["//platform/icons-api"] +) + +jvm_library( + name = "rendering_test_lib", + module_name = "intellij.platform.icons.api.rendering", + visibility = ["//visibility:public"], + srcs = glob([], allow_empty = True), + exports = [ + "//platform/icons-api", + "//platform/icons-api:icons-api_test_lib", + ], + runtime_deps = [ + ":rendering", + "//libraries/kotlinx/coroutines/core:core_test_lib", + "//platform/icons-api:icons-api_test_lib", + ] +) +### auto-generated section `build intellij.platform.icons.api.rendering` end \ No newline at end of file diff --git a/platform/icons-api/rendering/build.gradle.kts b/platform/icons-api/rendering/build.gradle.kts new file mode 100644 index 0000000000000..60b910a41059b --- /dev/null +++ b/platform/icons-api/rendering/build.gradle.kts @@ -0,0 +1,26 @@ +// This file is used by Jewel gradle script, check community/platform/jewel + +plugins { + jewel + alias(libs.plugins.kotlinx.serialization) +} + +sourceSets { + main { + kotlin { + setSrcDirs(listOf("src")) + } + } + + test { + kotlin { + setSrcDirs(listOf("test")) + } + } +} + +dependencies { + api("org.jetbrains:annotations:26.0.2") + api(project(":jb-icons-api")) + api(libs.kotlinx.coroutines.core) +} diff --git a/platform/icons-api/rendering/intellij.platform.icons.api.rendering.iml b/platform/icons-api/rendering/intellij.platform.icons.api.rendering.iml new file mode 100644 index 0000000000000..8205298f71adb --- /dev/null +++ b/platform/icons-api/rendering/intellij.platform.icons.api.rendering.iml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/BitmapImageResource.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/BitmapImageResource.kt new file mode 100644 index 0000000000000..6b411a74e781d --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/BitmapImageResource.kt @@ -0,0 +1,24 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Internal +interface BitmapImageResource : ImageResource { + fun getRGBPixels(): IntArray + fun readPrefetchedPixel(pixels: IntArray, x: Int, y: Int): Int? + fun getBandOffsetsToSRGB(): IntArray + + override val width: Int + override val height: Int +} + +@ApiStatus.Internal +object EmptyBitmapImageResource : BitmapImageResource { + override fun getRGBPixels(): IntArray = intArrayOf() + override fun readPrefetchedPixel(pixels: IntArray, x: Int, y: Int): Int = 0 + override fun getBandOffsetsToSRGB(): IntArray = intArrayOf(0, 1, 2, 3) + + override val width: Int = 0 + override val height: Int = 0 +} diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/Bounds.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/Bounds.kt new file mode 100644 index 0000000000000..165a21f4cb59f --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/Bounds.kt @@ -0,0 +1,84 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Internal +class Bounds( + val x: Int, + val y: Int, + width: Int, + height: Int +): Dimensions(width, height) { + + + fun copy( + x: Int = this.x, + y: Int = this.y, + width: Int = this.width, + height: Int = this.height + ): Bounds { + return Bounds( + x, + y, + width, + height + ) + } + + fun canFit(other: Bounds): Boolean = other.width <= width && other.height <= height + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + + other as Bounds + + if (x != other.x) return false + if (y != other.y) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + x + result = 31 * result + y + return result + } + + override fun toString(): String { + return "Bounds(x=$x, y=$y, width=$width, height=$height)" + } + + +} + +@ApiStatus.Experimental +open class Dimensions( + val width: Int, + val height: Int +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Dimensions + + if (width != other.width) return false + if (height != other.height) return false + + return true + } + + override fun hashCode(): Int { + var result = width + result = 31 * result + height + return result + } + + override fun toString(): String { + return "Dimensions(width=$width, height=$height)" + } +} \ No newline at end of file diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/IconRenderer.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/IconRenderer.kt new file mode 100644 index 0000000000000..c128b7b7d89f8 --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/IconRenderer.kt @@ -0,0 +1,14 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.Icon + +@ApiStatus.Experimental +interface IconRenderer { + val icon: Icon + @ApiStatus.Internal + fun render(api: PaintingApi) + @ApiStatus.Internal + fun calculateExpectedDimensions(scaling: ScalingContext): Dimensions +} \ No newline at end of file diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/IconRendererManager.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/IconRendererManager.kt new file mode 100644 index 0000000000000..26b6a9b27efeb --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/IconRendererManager.kt @@ -0,0 +1,59 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import kotlinx.coroutines.CoroutineScope +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.Icon +import java.util.ServiceLoader + +/** + * Manager for Icon renderers, use convenience methods instead (like Icon.createRenderer()) + */ +@ApiStatus.Experimental +interface IconRendererManager { + /** + * This method will create renderer for specific icon, keep in mind that this might be an expensive operation. + * Use of Icon.createRenderer() is recommended + * @param context General render context, this can be used to watch for Icon updates, or set defaults for color filters etc. + * @param loadingStrategy Dictates how the Icon loading should be performed, like block thread, show placeholder, or render blank area + */ + @ApiStatus.Experimental + fun createRenderer(icon: Icon, context: RenderingContext, loadingStrategy: LoadingStrategy = LoadingStrategy.BlockThread): IconRenderer + + fun createUpdateFlow(scope: CoroutineScope?, updateCallback: (Int) -> Unit): MutableIconUpdateFlow + + fun createRenderingContext(updateFlow: MutableIconUpdateFlow, defaultImageModifiers: ImageModifiers? = null): RenderingContext + + companion object { + @Volatile + private var instance: IconRendererManager? = null + + @JvmStatic + fun getInstance(): IconRendererManager = instance ?: loadFromSPI() + + private fun loadFromSPI(): IconRendererManager = + ServiceLoader.load(IconRendererManager::class.java).firstOrNull() + ?: error("IconRendererManager instance is not set and there is no SPI service on classpath.") + + fun createUpdateFlow(scope: CoroutineScope?, updateCallback: (Int) -> Unit): MutableIconUpdateFlow = getInstance().createUpdateFlow(scope, updateCallback) + + fun activate(manager: IconRendererManager) { + instance = manager + } + + fun createRenderingContext(updateFlow: MutableIconUpdateFlow, defaultImageModifiers: ImageModifiers? = null): RenderingContext { + return getInstance().createRenderingContext(updateFlow, defaultImageModifiers) + } + } +} + +/** + * This method will create renderer for specific icon, keep in mind that this might be an expensive operation. + * Use of Icon.createRenderer() is recommended over getting the IconRendererManager directly + * @param context General render context, this can be used to watch for Icon updates, or set defaults for color filters etc. + * @param loadingStrategy Dictates how the Icon loading should be performed, like block thread, show placeholder, or render blank area + */ +@ApiStatus.Experimental +fun Icon.createRenderer(context: RenderingContext, loadingStrategy: LoadingStrategy = LoadingStrategy.BlockThread): IconRenderer { + return IconRendererManager.getInstance().createRenderer(this, context, loadingStrategy) +} diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/IconUpdateFlow.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/IconUpdateFlow.kt new file mode 100644 index 0000000000000..a4789748ae1fc --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/IconUpdateFlow.kt @@ -0,0 +1,15 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import kotlinx.coroutines.flow.Flow +import org.jetbrains.icons.Icon +import org.jetbrains.annotations.ApiStatus + +typealias IconUpdateFlow = Flow + +@ApiStatus.Internal +interface MutableIconUpdateFlow: IconUpdateFlow { + fun triggerUpdate() + fun triggerDelayedUpdate(delay: Long) + fun collectDynamic(flow: Flow, handler: (Icon) -> Unit) {} +} \ No newline at end of file diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/ImageResource.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/ImageResource.kt new file mode 100644 index 0000000000000..38990df4bc953 --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/ImageResource.kt @@ -0,0 +1,30 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.ImageResourceLocation +import org.jetbrains.icons.filters.ColorFilter +import org.jetbrains.icons.patchers.SvgPatcher + +@ApiStatus.Experimental +fun imageResource(loader: ImageResourceLocation, imageModifiers: ImageModifiers? = null): ImageResource = ImageResourceProvider.getInstance().loadImage(loader, imageModifiers) + +@ApiStatus.Experimental +interface ImageModifiers { + val colorFilter: ColorFilter? + val svgPatcher: SvgPatcher? +} + +@ApiStatus.Experimental +interface ImageResource { + /** + * Image width in pixels, if the image is rescalable this should return default size or null if default size is not set. + */ + val width: Int? + /** + * Image height in pixels, if the image is rescalable this should return default size or null if default size is not set. + */ + val height: Int? + + companion object +} \ No newline at end of file diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/ImageResourceLoader.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/ImageResourceLoader.kt new file mode 100644 index 0000000000000..92ce025303afe --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/ImageResourceLoader.kt @@ -0,0 +1,20 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import org.jetbrains.icons.ImageResourceLocation + +interface GenericImageResourceLoader { + fun loadGenericImage(location: ImageResourceLocation, imageModifiers: ImageModifiers? = null): ImageResource? +} + +@Suppress("UNCHECKED_CAST") +interface ImageResourceLoader: GenericImageResourceLoader { + override fun loadGenericImage( + location: ImageResourceLocation, + imageModifiers: ImageModifiers?, + ): ImageResource? { + return loadImage(location as? TLocation ?: error("Unsupported image resource location."), imageModifiers) + } + + fun loadImage(location: TLocation, imageModifiers: ImageModifiers? = null): ImageResource? +} \ No newline at end of file diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/ImageResourceProvider.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/ImageResourceProvider.kt new file mode 100644 index 0000000000000..3d995fb96310f --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/ImageResourceProvider.kt @@ -0,0 +1,27 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import org.jetbrains.icons.ImageResourceLocation +import org.jetbrains.annotations.ApiStatus +import java.util.ServiceLoader + +@ApiStatus.Internal +interface ImageResourceProvider { + fun loadImage(location: ImageResourceLocation, imageModifiers: ImageModifiers? = null): ImageResource + + companion object { + @Volatile + private var instance: ImageResourceProvider? = null + + @JvmStatic + fun getInstance(): ImageResourceProvider = instance ?: loadFromSPI() + + private fun loadFromSPI(): ImageResourceProvider = + ServiceLoader.load(ImageResourceProvider::class.java).firstOrNull() + ?: error("ImageResourceProvider instance is not set and there is no SPI service on classpath.") + + fun activate(provider: ImageResourceProvider) { + instance = provider + } + } +} \ No newline at end of file diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/LoadingOptions.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/LoadingOptions.kt new file mode 100644 index 0000000000000..1a861625ac5d4 --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/LoadingOptions.kt @@ -0,0 +1,17 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +sealed interface LoadingStrategy { + class RenderBlank( + val dimensions: Dimensions + ) : LoadingStrategy + + class RenderPlaceholder( + val placeHolder: IconRenderer + ) : LoadingStrategy + + object BlockThread : LoadingStrategy +} \ No newline at end of file diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/PaintingApi.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/PaintingApi.kt new file mode 100644 index 0000000000000..0a79783255853 --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/PaintingApi.kt @@ -0,0 +1,45 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.Color +import org.jetbrains.icons.filters.ColorFilter + +/** + * Abstraction of painting API, this is used to define icons or graphics that are customizable + * but also reusable between different environments, where graphic api differ. + */ +@ApiStatus.Internal +interface PaintingApi { + val bounds: Bounds + val scaling: ScalingContext + fun drawImage( + image: ImageResource, + x: Int = 0, + y: Int = 0, + width: Int? = null, + height: Int? = null, + srcX: Int = 0, + srcY: Int = 0, + srcWidth: Int? = null, + srcHeight: Int? = null, + alpha: Float = 1.0f, + colorFilter: ColorFilter? = null + ) + fun drawCircle(color: Color, x: Int, y: Int, radius: Float, alpha: Float = 1f, mode: DrawMode = DrawMode.Fill) + fun drawRect(color: Color, x: Int, y: Int, width: Int, height: Int, alpha: Float = 1f, mode: DrawMode = DrawMode.Fill) + fun getUsedBounds(): Bounds + fun withCustomContext(bounds: Bounds, overrideColorFilter: ColorFilter? = null): PaintingApi +} + +@ApiStatus.Internal +enum class DrawMode { + Fill, + Clear, + Stroke +} + +@ApiStatus.Internal +interface ScalingContext { + val display: Float +} \ No newline at end of file diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/RenderingContext.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/RenderingContext.kt new file mode 100644 index 0000000000000..25b257e70c2b9 --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/RenderingContext.kt @@ -0,0 +1,30 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import org.jetbrains.annotations.ApiStatus + +/** + * Rendering context affects the behavior of the actual renderers and can be used to, for example, pass update callbacks. + */ +@ApiStatus.Experimental +class RenderingContext( + /** + * Update flow notifies the Icon renderer user about the need to force-rerender the Icon if necessary. (for example on animation frame) + */ + val updateFlow: MutableIconUpdateFlow, + /** + * Default image modifiers that will be applied to the Icon renderer, can be used to set default color filters etc. + * This is mainly used when Icon is layered into another Icon, to pass the color filters etc., but can be also used + * to pass the filters from the component Icon should be rendered in. + */ + val defaultImageModifiers: ImageModifiers? = null +) { + fun copy(updateFlow: MutableIconUpdateFlow? = null, defaultImageModifiers: ImageModifiers? = null): RenderingContext = RenderingContext( + updateFlow ?: this.updateFlow, + defaultImageModifiers ?: this.defaultImageModifiers + ) + + companion object { + val Empty: RenderingContext = RenderingContext(IconRendererManager.createUpdateFlow(null) { }) + } +} \ No newline at end of file diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/RescalableImageResource.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/RescalableImageResource.kt new file mode 100644 index 0000000000000..44dc7cba18445 --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/RescalableImageResource.kt @@ -0,0 +1,60 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering + +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Internal +interface RescalableImageResource : ImageResource { + fun scale(scale: ImageScale): BitmapImageResource + fun calculateExpectedDimensions(scale: ImageScale): Bounds +} + +@ApiStatus.Internal +sealed interface ImageScale { + fun calculateScalingFactorByOriginalDimensions(width: Int, height: Int): Float +} + +@ApiStatus.Internal +class FixedScale(val scale: Float) : ImageScale { + override fun calculateScalingFactorByOriginalDimensions(width: Int, height: Int): Float = scale + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FixedScale + + return scale == other.scale + } + + override fun hashCode(): Int { + return scale.hashCode() + } +} + +@ApiStatus.Internal +class FitAreaScale(val width: Int, val height: Int) : ImageScale { + override fun calculateScalingFactorByOriginalDimensions(width: Int, height: Int): Float { + val wscale = this.width / width.toFloat() + val hscale = this.height / height.toFloat() + return wscale.coerceAtMost(hscale) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as FitAreaScale + + if (width != other.width) return false + if (height != other.height) return false + + return true + } + + override fun hashCode(): Int { + var result = width + result = 31 * result + height + return result + } +} diff --git a/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/lowlevel/GPUImageResourceHolder.kt b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/lowlevel/GPUImageResourceHolder.kt new file mode 100644 index 0000000000000..07ad6e73603f0 --- /dev/null +++ b/platform/icons-api/rendering/src/org/jetbrains/icons/rendering/lowlevel/GPUImageResourceHolder.kt @@ -0,0 +1,10 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.rendering.lowlevel + +import org.jetbrains.annotations.ApiStatus +import kotlin.reflect.KClass + +@ApiStatus.Internal +interface GPUImageResourceHolder { + fun getOrGenerateBitmap(bitmapClass: KClass, generator: () -> TBitmap): TBitmap +} diff --git a/platform/icons-api/src/org/jetbrains/icons/DeferredIcon.kt b/platform/icons-api/src/org/jetbrains/icons/DeferredIcon.kt new file mode 100644 index 0000000000000..7f4cd00af8735 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/DeferredIcon.kt @@ -0,0 +1,18 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons + +import org.jetbrains.annotations.ApiStatus + +/** + * Deferred Icon takes time to resolve; therefore, it is postponed to be resolved later. + * Placeholder icon can be set to provide a visual representation while the actual icon is being resolved. + * Unlike the older API, to force evaluation or get the resolved icon, IconManager should be used. + * + * @see IconManager.deferredIcon + * @see IconManager.forceEvaluationnnnn + */ +@ApiStatus.Experimental +interface DeferredIcon: Icon { + val id: IconIdentifier + val placeholder: Icon? +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/Icon.kt b/platform/icons-api/src/org/jetbrains/icons/Icon.kt new file mode 100644 index 0000000000000..5d15bd7428d6f --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/Icon.kt @@ -0,0 +1,19 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Polymorphic +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.jetbrains.annotations.ApiStatus + +/** + * This is universal Icon interface that can be used across different environments with different graphics api. + * (or without one) + * + * For serialization, use getSerializersModule() on IconManager + */ +@ApiStatus.Experimental +interface Icon \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/IconIdentifier.kt b/platform/icons-api/src/org/jetbrains/icons/IconIdentifier.kt new file mode 100644 index 0000000000000..8e992085b8e47 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/IconIdentifier.kt @@ -0,0 +1,4 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons + +interface IconIdentifier \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/IconManager.kt b/platform/icons-api/src/org/jetbrains/icons/IconManager.kt new file mode 100644 index 0000000000000..95fb7d7764f52 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/IconManager.kt @@ -0,0 +1,88 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons + +import kotlinx.serialization.modules.SerializersModule +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.IconDesigner +import org.jetbrains.icons.modifiers.IconModifier +import java.util.ServiceLoader + +@ApiStatus.Experimental +interface IconManager { + /** + * Creates new Icon "description", this is a cheap operation, to render the Icon, use Icon.createRenderer() function. + * Use convenience top-level method icon {} instead if possible. + */ + fun icon(designer: IconDesigner.() -> Unit): Icon + + /** + * @see org.jetbrains.icons.deferredIcon + */ + fun deferredIcon(placeholder: Icon?, identifier: String? = null, classLoader: ClassLoader? = null, evaluator: suspend () -> Icon): Icon + + suspend fun forceEvaluation(icon: DeferredIcon): Icon + + /** + * Converts specific Icon to swing Icon. + * ! This is an expensive operation and can include image loading, reuse the instance if possible. ! + */ + fun toSwingIcon(icon: Icon): javax.swing.Icon + + fun getSerializersModule(): SerializersModule + + companion object { + @Volatile + private var instance: IconManager? = null + + @JvmStatic + fun getInstance(): IconManager = instance ?: loadFromSPI() + + private fun loadFromSPI(): IconManager = + ServiceLoader.load(IconManager::class.java).firstOrNull() + ?: error("IconManager instance is not set and there is no SPI service on classpath.") + + + fun activate(manager: IconManager) { + instance = manager + } + } +} + +/** + * Creates new Icon + * The result should be serializable and contains description of what the Icon should look like. + * + * To render the Icon, renderer has to be obtained first using createRenderer(), however this should be only done + * from inside components. (check intellij.platform.icons.api.rendering module), for usage inside swing, + * use toSwingIcon method. + * + * Usage: + * ''' + * icon { + * image("icons/icon.svg", MyClass::class.java.classLoader) + * } + * ''' + * + * Check the designer interface for layer options. Also check intellij.platform.icons.api.legacyIconSupport module + * to find out how to convert old icons and new icons. + * + * @see IconManager.toSwingIcon + */ +fun icon(designer: IconDesigner.() -> Unit): Icon = IconManager.getInstance().icon(designer) + +/** + * Deferred icon allows apis to return an Icon that takes some time to compute; + * optional placeholder can be included to allow rendering it before the actual icon is ready. + * + * To cache such icons and synchronize them over the network, some identifier should be given. + * Implementations might try to prefix the identifier with the source pluginId/moduleId to avoid clashes. + * + * If the identifier is not passed, an automatic one is created, however, this will prevent the result + * from being cached, as a new one is generated per each deferredIcon() call. + * + * @param classLoader This classLoader might be used to prevent id clashes (prefix with pluginId & moduleId if possible for example) + */ +fun deferredIcon(placeholder: Icon?, identifier: String? = null, classLoader: ClassLoader? = null, evaluator: suspend () -> Icon): Icon = + IconManager.getInstance().deferredIcon(placeholder, identifier, classLoader, evaluator) + +fun imageIcon(path: String, classLoader: ClassLoader? = null, modifier: IconModifier = IconModifier): Icon = icon { image(path, classLoader, modifier) } \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/ImageResourceLocation.kt b/platform/icons-api/src/org/jetbrains/icons/ImageResourceLocation.kt new file mode 100644 index 0000000000000..4ac3d7ed22a29 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/ImageResourceLocation.kt @@ -0,0 +1,18 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons + +import org.jetbrains.annotations.ApiStatus + +/** + * Represents a place from which image resource can be loaded. + * + * For example: + * - path + * - pluginId + * - moduleId + * + * When creating new locations, ensure to register new ImageResourceLoader (rendering api) for the specific location. + * Check current implementation on how to register extensions. + */ +@ApiStatus.Experimental +interface ImageResourceLocation \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/design/BlendMode.kt b/platform/icons-api/src/org/jetbrains/icons/design/BlendMode.kt new file mode 100644 index 0000000000000..e545e500a0c2b --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/design/BlendMode.kt @@ -0,0 +1,16 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.design + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +@Serializable +enum class BlendMode { + SrcIn, + Hue, + Saturation, + Luminosity, + Color, + Multiply +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/design/Color.kt b/platform/icons-api/src/org/jetbrains/icons/design/Color.kt new file mode 100644 index 0000000000000..c3f03ff2d1e58 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/design/Color.kt @@ -0,0 +1,81 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.design + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus +import kotlin.math.roundToInt + +@ApiStatus.Experimental +@Serializable +sealed interface Color { + fun toHex(): String + + companion object { + val Transparent: Color = RGBA(0f, 0f, 0f, 0f) + } +} + +@ApiStatus.Experimental +@Serializable +class RGBA( + val red: Float, + val green: Float, + val blue: Float, + val alpha: Float +): Color { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RGBA + + if (red != other.red) return false + if (green != other.green) return false + if (blue != other.blue) return false + if (alpha != other.alpha) return false + + return true + } + + override fun hashCode(): Int { + var result = red.hashCode() + result = 31 * result + green.hashCode() + result = 31 * result + blue.hashCode() + result = 31 * result + alpha.hashCode() + return result + } + + override fun toString(): String { + return "RGBA(red=$red, green=$green, blue=$blue, alpha=$alpha)" + } + + override fun toHex(): String { + val r = Integer.toHexString((red * 255).roundToInt()) + val g = Integer.toHexString((green * 255).roundToInt()) + val b = Integer.toHexString((blue * 255).roundToInt()) + val intAlpha = (alpha * 255).roundToInt() + + return formatColorRgbaHexString(r, g, b, intAlpha, true, true) + } + + private fun formatColorRgbaHexString( + rString: String, + gString: String, + bString: String, + alphaInt: Int, + includeHashSymbol: Boolean, + omitAlphaWhenFullyOpaque: Boolean, + ): String = buildString { + if (includeHashSymbol) append('#') + + append(rString.padStart(2, '0')) + append(gString.padStart(2, '0')) + append(bString.padStart(2, '0')) + + if (alphaInt < 255 || !omitAlphaWhenFullyOpaque) { + val a = Integer.toHexString(alphaInt) + append(a.padStart(2, '0')) + } + } + +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/design/IconAlign.kt b/platform/icons-api/src/org/jetbrains/icons/design/IconAlign.kt new file mode 100644 index 0000000000000..2031d2aaeed0e --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/design/IconAlign.kt @@ -0,0 +1,61 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.design + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +@Suppress("unused") +@Serializable +@ApiStatus.Experimental +class IconAlign( + val verticalAlign: IconVerticalAlign, + val horizontalAlign: IconHorizontalAlign +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IconAlign + + if (verticalAlign != other.verticalAlign) return false + if (horizontalAlign != other.horizontalAlign) return false + + return true + } + + override fun hashCode(): Int { + var result = verticalAlign.hashCode() + result = 31 * result + horizontalAlign.hashCode() + return result + } + + override fun toString(): String { + return "IconAlign(verticalAlign=$verticalAlign, horizontalAlign=$horizontalAlign)" + } + + companion object { + val TopLeft: IconAlign = IconAlign(IconVerticalAlign.Top, IconHorizontalAlign.Left) + val TopCenter: IconAlign = IconAlign(IconVerticalAlign.Top, IconHorizontalAlign.Center) + val TopRight: IconAlign = IconAlign(IconVerticalAlign.Top, IconHorizontalAlign.Right) + val CenterLeft: IconAlign = IconAlign(IconVerticalAlign.Center, IconHorizontalAlign.Left) + val Center: IconAlign = IconAlign(IconVerticalAlign.Center, IconHorizontalAlign.Center) + val CenterRight: IconAlign = IconAlign(IconVerticalAlign.Center, IconHorizontalAlign.Right) + val BottomLeft: IconAlign = IconAlign(IconVerticalAlign.Bottom, IconHorizontalAlign.Left) + val BottomCenter: IconAlign = IconAlign(IconVerticalAlign.Bottom, IconHorizontalAlign.Center) + val BottomRight: IconAlign = IconAlign(IconVerticalAlign.Bottom, IconHorizontalAlign.Right) + } +} + +@Serializable +enum class IconVerticalAlign { + Top, + Center, + Bottom +} + +@Serializable +enum class IconHorizontalAlign { + Left, + Center, + Right +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/design/IconDesigner.kt b/platform/icons-api/src/org/jetbrains/icons/design/IconDesigner.kt new file mode 100644 index 0000000000000..63b959cef14d2 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/design/IconDesigner.kt @@ -0,0 +1,49 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.design + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.Icon +import org.jetbrains.icons.ImageResourceLocation +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.modifiers.IconModifier +import org.jetbrains.icons.modifiers.align +import org.jetbrains.icons.modifiers.cutoutMargin +import org.jetbrains.icons.modifiers.size + +/** + * Individual methods act as layers and the order dictates in which order they are rendered. + * + * @param IconModifier Modifications that should be performed on the Layer, like sizing, margin, color filters etc. (order-dependant) + */ +@ApiStatus.Experimental +interface IconDesigner { + fun image(resourceLoader: ImageResourceLocation, modifier: IconModifier = IconModifier) + fun image(path: String, classLoader: ClassLoader? = null, modifier: IconModifier = IconModifier) + fun icon(icon: Icon, modifier: IconModifier = IconModifier) + fun box(modifier: IconModifier = IconModifier, builder: IconDesigner.() -> Unit) + fun row(spacing: IconUnit = 0.px, modifier: IconModifier = IconModifier, builder: IconDesigner.() -> Unit) + fun column(spacing: IconUnit = 0.px, modifier: IconModifier = IconModifier, builder: IconDesigner.() -> Unit) + fun animation(modifier: IconModifier = IconModifier, builder: IconAnimationDesigner.() -> Unit) + fun shape(color: Color, shape: Shape, modifier: IconModifier) + /** + * Adds custom layer type to this designer, keep in mind that additional registration of serializers/renderers is needed + * for the layer to be used. Check the specific Icon Manager used to see registration details. + */ + fun custom(iconLayer: IconLayer) +} + +fun IconDesigner.badge( + color: Color, + shape: Shape, + size: IconUnit = (3.5 * 2).dp relativeTo 20.dp, + align: IconAlign = IconAlign.TopRight, + cutout: IconUnit = 1.5.dp relativeTo 20.dp, + modifier: IconModifier = IconModifier, +) { + shape(color, shape, modifier.size(size).align(align).cutoutMargin(cutout)) +} + +@ApiStatus.Experimental +interface IconAnimationDesigner { + fun frame(duration: Long, builder: IconDesigner.() -> Unit) +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/design/IconMargin.kt b/platform/icons-api/src/org/jetbrains/icons/design/IconMargin.kt new file mode 100644 index 0000000000000..decbdb2206374 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/design/IconMargin.kt @@ -0,0 +1,44 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.design + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +@Serializable +@ApiStatus.Experimental +class IconMargin( + val top: IconUnit, + val left: IconUnit, + val bottom: IconUnit, + val right: IconUnit, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IconMargin + + if (top != other.top) return false + if (left != other.left) return false + if (bottom != other.bottom) return false + if (right != other.right) return false + + return true + } + + override fun hashCode(): Int { + var result = top.hashCode() + result = 31 * result + left.hashCode() + result = 31 * result + bottom.hashCode() + result = 31 * result + right.hashCode() + return result + } + + override fun toString(): String { + return "IconMargin(top=$top, left=$left, bottom=$bottom, right=$right)" + } + + companion object { + val Zero: IconMargin = IconMargin(0.dp, 0.dp, 0.dp, 0.dp) + } +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/design/IconUnits.kt b/platform/icons-api/src/org/jetbrains/icons/design/IconUnits.kt new file mode 100644 index 0000000000000..d75289462deaa --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/design/IconUnits.kt @@ -0,0 +1,153 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.design + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +/** + * Samples: + *
+ *   20.px
+ *  * 100.percent
+ *  * 0.5.fraction
+ *  * 5.dp
+ *  * AutoIconUnit - fills max width
+ * 
+ */ +@Serializable +@ApiStatus.Experimental +sealed interface IconUnit { + companion object { + val Zero: IconUnit = 0.dp + val Auto: IconUnit = MaxIconUnit + } +} + +@Serializable +@ApiStatus.Experimental +class DisplayPointIconUnit(val value: Double) : IconUnit { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DisplayPointIconUnit + + return value == other.value + } + + override fun hashCode(): Int { + return value.hashCode() + } + + override fun toString(): String { + return "DisplayPointIconUnit(value=$value)" + } + +} + +@Serializable +@ApiStatus.Experimental +class PixelIconUnit(val value: Int) : IconUnit { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PixelIconUnit + + return value == other.value + } + + override fun hashCode(): Int { + return value + } + + override fun toString(): String { + return "PixelIconUnit(value=$value)" + } +} + +@Serializable +@ApiStatus.Experimental +class PercentIconUnit(val value: Double) : IconUnit { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PercentIconUnit + + return value == other.value + } + + override fun hashCode(): Int { + return value.hashCode() + } + + override fun toString(): String { + return "PercentIconUnit(value=$value)" + } +} + +@Serializable +@ApiStatus.Experimental +object MaxIconUnit : IconUnit + +val Int.dp: DisplayPointIconUnit + get() = DisplayPointIconUnit(this.toDouble()) + +val Double.dp: DisplayPointIconUnit + get() = DisplayPointIconUnit(this) + +val Float.dp: DisplayPointIconUnit + get() = DisplayPointIconUnit(this.toDouble()) + +val Int.px: PixelIconUnit + get() = PixelIconUnit(this) + +/** + * The resulting value is divided by 100, so the number before this represents actual percentage (not fraction) + * @see fraction + */ +val Int.percent: PercentIconUnit + get() = this.toDouble().percent + +/** + * The resulting value is divided by 100, so the number before this represents actual percentage (not fraction) + * @see fraction + */ +val Double.percent: PercentIconUnit + get() = PercentIconUnit(this / 100.0) + +/** + * The resulting value is divided by 100, so the number before this represents actual percentage (not fraction) + * @see fraction + */ +val Float.percent: PercentIconUnit + get() = this.toDouble().percent +/** + * The resulting value is not divided by 100, acts as a percentage of bounds + * @see percent + */ +val Int.fraction: PercentIconUnit + get() = this.toDouble().fraction + +/** + * The resulting value is not divided by 100, acts as a percentage of bounds + * @see percent + */ +val Double.fraction: PercentIconUnit + get() = PercentIconUnit(this) + +/** + * The resulting value is not divided by 100, acts as a percentage of bounds + * @see percent + */ +val Float.fraction: PercentIconUnit + get() = this.toDouble().fraction + +infix fun PixelIconUnit.relativeTo(bound: PixelIconUnit): PercentIconUnit { + return (this.value / bound.value).fraction +} + +infix fun DisplayPointIconUnit.relativeTo(bound: DisplayPointIconUnit): PercentIconUnit { + return (this.value / bound.value).fraction +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/design/Shape.kt b/platform/icons-api/src/org/jetbrains/icons/design/Shape.kt new file mode 100644 index 0000000000000..3296909b54636 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/design/Shape.kt @@ -0,0 +1,27 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.design + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +@Serializable +sealed interface Shape { + companion object { + } +} + +@ApiStatus.Experimental +@Serializable +object Circle: Shape { + override fun toString(): String { + return "Circle" + } + +} + +@ApiStatus.Experimental +@Serializable +object Rectangle: Shape { + +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/design/SvgPatcherDesigner.kt b/platform/icons-api/src/org/jetbrains/icons/design/SvgPatcherDesigner.kt new file mode 100644 index 0000000000000..1df720ce7ce69 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/design/SvgPatcherDesigner.kt @@ -0,0 +1,55 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.design + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.patchers.SvgPatchOperation +import org.jetbrains.icons.patchers.SvgPatcher +import org.jetbrains.icons.patchers.SvgPathFilteredOperations + +@ApiStatus.Experimental +public class SvgPatcherDesigner { + private val operations = mutableListOf() + private val filteredOperations = mutableListOf() + + fun replace(name: String, newValue: String) { + operations.add(SvgPatchOperation(name, newValue, false, false, null, SvgPatchOperation.Operation.Replace)) + } + + fun replaceIfMatches(name: String, expectedValue: String, newValue: String) { + operations.add(SvgPatchOperation(name, newValue, true, false, expectedValue, SvgPatchOperation.Operation.Replace)) + } + + fun replaceUnlessMatches(name: String, expectedValue: String, newValue: String) { + operations.add(SvgPatchOperation(name, newValue, true, true, expectedValue, SvgPatchOperation.Operation.Replace)) + } + + fun removeIfMatches(name: String, expectedValue: String) { + operations.add(SvgPatchOperation(name, null, true, false, expectedValue, SvgPatchOperation.Operation.Remove)) + } + + fun removeUnlessMatches(name: String, expectedValue: String) { + operations.add(SvgPatchOperation(name, null, true, true, expectedValue, SvgPatchOperation.Operation.Remove)) + } + + fun remove(name: String) { + operations.add(SvgPatchOperation(name, null, false, false, null, SvgPatchOperation.Operation.Remove)) + } + + fun set(name: String, value: String) { + operations.add(SvgPatchOperation(name, value, false, false, null, SvgPatchOperation.Operation.Add)) + } + + fun add(name: String, value: String) { + operations.add(SvgPatchOperation(name, value, false, false, null, SvgPatchOperation.Operation.Add)) + } + + fun filter(path: String, svgPatcherDesigner: SvgPatcherDesigner.() -> Unit) { + val designer = SvgPatcherDesigner() + svgPatcherDesigner.invoke(designer) + // TODO Support recursive filters + filteredOperations.add(SvgPathFilteredOperations(path, designer.build().operations)) + } + + @ApiStatus.Internal + internal fun build(): SvgPatcher = SvgPatcher(operations, filteredOperations) +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/filters/ColorFilter.kt b/platform/icons-api/src/org/jetbrains/icons/filters/ColorFilter.kt new file mode 100644 index 0000000000000..652cef6bc0fca --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/filters/ColorFilter.kt @@ -0,0 +1,9 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.filters + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +@Serializable +sealed interface ColorFilter \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/filters/TintColorFilter.kt b/platform/icons-api/src/org/jetbrains/icons/filters/TintColorFilter.kt new file mode 100644 index 0000000000000..361d2baed6c27 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/filters/TintColorFilter.kt @@ -0,0 +1,15 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.filters + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.BlendMode +import org.jetbrains.icons.design.Color + +@ApiStatus.Experimental +@Serializable +class TintColorFilter( + val color: Color, + val blendMode: BlendMode = BlendMode.SrcIn +): ColorFilter { +} diff --git a/platform/icons-api/src/org/jetbrains/icons/layers/IconLayer.kt b/platform/icons-api/src/org/jetbrains/icons/layers/IconLayer.kt new file mode 100644 index 0000000000000..851a6af8d36a6 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/layers/IconLayer.kt @@ -0,0 +1,13 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.layers + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.modifiers.IconModifier + +/** + * To serialize IconLayer, serializersModule from IconManager might be used. + */ +@ApiStatus.Experimental +interface IconLayer { + val modifier: IconModifier +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/modifiers/AlignIconModifier.kt b/platform/icons-api/src/org/jetbrains/icons/modifiers/AlignIconModifier.kt new file mode 100644 index 0000000000000..9bd82963650f0 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/modifiers/AlignIconModifier.kt @@ -0,0 +1,33 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.modifiers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.IconAlign + +@Serializable +@ApiStatus.Experimental +class AlignIconModifier( + val align: IconAlign +): IconModifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AlignIconModifier + + return align == other.align + } + + override fun hashCode(): Int { + return align.hashCode() + } + + override fun toString(): String { + return "AlignIconModifier(align=$align)" + } +} + +fun IconModifier.align(align: IconAlign): IconModifier { + return this then AlignIconModifier(align) +} diff --git a/platform/icons-api/src/org/jetbrains/icons/modifiers/AlphaIconModifier.kt b/platform/icons-api/src/org/jetbrains/icons/modifiers/AlphaIconModifier.kt new file mode 100644 index 0000000000000..8b33601265a6a --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/modifiers/AlphaIconModifier.kt @@ -0,0 +1,32 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.modifiers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +@Serializable +@ApiStatus.Experimental +class AlphaIconModifier( + val alpha: Float +): IconModifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AlphaIconModifier + + return alpha == other.alpha + } + + override fun hashCode(): Int { + return alpha.hashCode() + } + + override fun toString(): String { + return "AlphaIconModifier(alpha=$alpha)" + } +} + +fun IconModifier.alpha(alpha: Float): IconModifier { + return this then AlphaIconModifier(alpha) +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/modifiers/ColorFilterModifier.kt b/platform/icons-api/src/org/jetbrains/icons/modifiers/ColorFilterModifier.kt new file mode 100644 index 0000000000000..e7f2efa178de0 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/modifiers/ColorFilterModifier.kt @@ -0,0 +1,41 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.modifiers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.BlendMode +import org.jetbrains.icons.design.Color +import org.jetbrains.icons.filters.ColorFilter +import org.jetbrains.icons.filters.TintColorFilter + +@Serializable +@ApiStatus.Experimental +class ColorFilterModifier( + val colorFilter: ColorFilter +): IconModifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ColorFilterModifier + + return colorFilter == other.colorFilter + } + + override fun hashCode(): Int { + return colorFilter.hashCode() + } + + override fun toString(): String { + return "ColorFilterModifier(colorFilter=$colorFilter)" + } + +} + +fun IconModifier.colorFilter(colorFilter: ColorFilter): IconModifier { + return this then ColorFilterModifier(colorFilter) +} + +fun IconModifier.tintColor(color: Color, blendMode: BlendMode): IconModifier { + return colorFilter(TintColorFilter(color, blendMode)) +} diff --git a/platform/icons-api/src/org/jetbrains/icons/modifiers/CombinedIconModifier.kt b/platform/icons-api/src/org/jetbrains/icons/modifiers/CombinedIconModifier.kt new file mode 100644 index 0000000000000..674fd63f5b0f2 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/modifiers/CombinedIconModifier.kt @@ -0,0 +1,35 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.modifiers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +@Serializable +@ApiStatus.Experimental +class CombinedIconModifier( + val root: IconModifier, + val other: IconModifier, +): IconModifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CombinedIconModifier + + if (root != other.root) return false + if (other != other.other) return false + + return true + } + + override fun hashCode(): Int { + var result = root.hashCode() + result = 31 * result + other.hashCode() + return result + } + + override fun toString(): String { + return "CombinedIconModifier(root=$root, other=$other)" + } + +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/modifiers/CutoutMarginModifier.kt b/platform/icons-api/src/org/jetbrains/icons/modifiers/CutoutMarginModifier.kt new file mode 100644 index 0000000000000..6dd71c315f69a --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/modifiers/CutoutMarginModifier.kt @@ -0,0 +1,38 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.modifiers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.IconUnit + +@Serializable +@ApiStatus.Experimental +/** + * Add cutout margin to the specific layer, which will clear the surrounding area + * Currently supported only by shape and image layers, image layer will not consider + * internal svg shape and will cut out rectangular area. + */ +class CutoutMarginModifier( + val size: IconUnit +): IconModifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CutoutMarginModifier + + return size == other.size + } + + override fun hashCode(): Int { + return size.hashCode() + } + + override fun toString(): String { + return "CutoutMarginModifier(size=$size)" + } +} + +fun IconModifier.cutoutMargin(size: IconUnit): IconModifier { + return this then CutoutMarginModifier(size) +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/modifiers/HeightIconModifier.kt b/platform/icons-api/src/org/jetbrains/icons/modifiers/HeightIconModifier.kt new file mode 100644 index 0000000000000..9c933a6f7da31 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/modifiers/HeightIconModifier.kt @@ -0,0 +1,42 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.modifiers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.MaxIconUnit +import org.jetbrains.icons.design.IconUnit + +@Serializable +@ApiStatus.Experimental +class HeightIconModifier( + val height: IconUnit +): IconModifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HeightIconModifier + + return height == other.height + } + + override fun hashCode(): Int { + return height.hashCode() + } + + override fun toString(): String { + return "HeightIconModifier(height=$height)" + } +} + +fun IconModifier.height(height: IconUnit): IconModifier { + return this then HeightIconModifier(height) +} + +fun IconModifier.size(size: IconUnit): IconModifier { + return this.width(size).height(size) +} + +fun IconModifier.fillMaxSize(): IconModifier { + return this.size(MaxIconUnit) +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/modifiers/IconModifier.kt b/platform/icons-api/src/org/jetbrains/icons/modifiers/IconModifier.kt new file mode 100644 index 0000000000000..7432ab82cf282 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/modifiers/IconModifier.kt @@ -0,0 +1,35 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.modifiers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +/** + * Modifications that should be performed on the Layer, like sizing, margin, color filters etc. (order-dependant) + * + * Sample usage: + * ''' + * icon { + * image("icons/icon.svg", IconModifier.margin(20.px)) + * } + * ''' + * + * @see AlignIconModifier + * @see MarginIconModifier + * @see HeightIconModifier + * @see WidthIconModifier + */ +@Serializable +@ApiStatus.Experimental +sealed interface IconModifier { + companion object: IconModifier +} + +/** + * Concatenates this modifier with another. + * + * Returns a [IconModifier] representing this modifier followed by [other] in sequence. + */ +@ApiStatus.Experimental +infix fun IconModifier.then(other: IconModifier): IconModifier = + if (other === IconModifier) this else CombinedIconModifier(this, other) \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/modifiers/MarginIconModifier.kt b/platform/icons-api/src/org/jetbrains/icons/modifiers/MarginIconModifier.kt new file mode 100644 index 0000000000000..77bf72121fbe1 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/modifiers/MarginIconModifier.kt @@ -0,0 +1,56 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.modifiers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.IconUnit + +@Serializable +@ApiStatus.Experimental +class MarginIconModifier( + val left: IconUnit, + val top: IconUnit, + val right: IconUnit, + val bottom: IconUnit +): IconModifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MarginIconModifier + + if (left != other.left) return false + if (top != other.top) return false + if (right != other.right) return false + if (bottom != other.bottom) return false + + return true + } + + override fun hashCode(): Int { + var result = left.hashCode() + result = 31 * result + top.hashCode() + result = 31 * result + right.hashCode() + result = 31 * result + bottom.hashCode() + return result + } + + override fun toString(): String { + return "MarginIconModifier(left=$left, top=$top, right=$right, bottom=$bottom)" + } +} + +@ApiStatus.Experimental +fun IconModifier.margin(left: IconUnit, top: IconUnit, right: IconUnit, bottom: IconUnit): IconModifier { + return this then MarginIconModifier(left, top, right, bottom) +} + +@ApiStatus.Experimental +fun IconModifier.margin(all: IconUnit): IconModifier { + return margin(all, all, all, all) +} + +@ApiStatus.Experimental +fun IconModifier.margin(vertical: IconUnit, horizontal: IconUnit): IconModifier { + return margin(horizontal, vertical, horizontal, vertical) +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/modifiers/StrokeModifier.kt b/platform/icons-api/src/org/jetbrains/icons/modifiers/StrokeModifier.kt new file mode 100644 index 0000000000000..5797d7c6dfb9f --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/modifiers/StrokeModifier.kt @@ -0,0 +1,35 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.modifiers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.Color + +@Serializable +@ApiStatus.Experimental +class StrokeModifier( + val color: Color +): IconModifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StrokeModifier + + return color == other.color + } + + override fun hashCode(): Int { + return color.hashCode() + } + + override fun toString(): String { + return "StrokeIconModifier(color=$color)" + } + +} + +@ApiStatus.Experimental +fun IconModifier.stroke(color: Color): IconModifier { + return this then StrokeModifier(color) +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/modifiers/SvgPatcherModifier.kt b/platform/icons-api/src/org/jetbrains/icons/modifiers/SvgPatcherModifier.kt new file mode 100644 index 0000000000000..b1d95c649406c --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/modifiers/SvgPatcherModifier.kt @@ -0,0 +1,44 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.modifiers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.SvgPatcherDesigner +import org.jetbrains.icons.patchers.SvgPatcher + +@Serializable +@ApiStatus.Experimental +class SvgPatcherModifier( + val svgPatcher: SvgPatcher +): IconModifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SvgPatcherModifier + + return svgPatcher == other.svgPatcher + } + + override fun hashCode(): Int { + return svgPatcher.hashCode() + } + + override fun toString(): String { + return "SvgPatcherModifier(svgPatcher=$svgPatcher)" + } + +} + +@ApiStatus.Experimental +fun IconModifier.patchSvg(svgPatcher: SvgPatcher): IconModifier { + return this then SvgPatcherModifier(svgPatcher) +} + +@ApiStatus.Experimental +fun IconModifier.patchSvg(svgPatcherBuilder: SvgPatcherDesigner.() -> Unit): IconModifier { + return this.patchSvg(svgPatcher(svgPatcherBuilder)) +} + +@ApiStatus.Experimental +fun svgPatcher(svgPatcherBuilder: SvgPatcherDesigner.() -> Unit): SvgPatcher = SvgPatcherDesigner().apply(svgPatcherBuilder).build() \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/modifiers/WidthIconModifier.kt b/platform/icons-api/src/org/jetbrains/icons/modifiers/WidthIconModifier.kt new file mode 100644 index 0000000000000..cc158e10843fb --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/modifiers/WidthIconModifier.kt @@ -0,0 +1,34 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.modifiers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.IconUnit + +@Serializable +@ApiStatus.Experimental +class WidthIconModifier( + val width: IconUnit +): IconModifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as WidthIconModifier + + return width == other.width + } + + override fun hashCode(): Int { + return width.hashCode() + } + + override fun toString(): String { + return "WidthIconModifier(width=$width)" + } +} + +@ApiStatus.Experimental +fun IconModifier.width(width: IconUnit): IconModifier { + return this then WidthIconModifier(width) +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/patchers/SvgPatcher.kt b/platform/icons-api/src/org/jetbrains/icons/patchers/SvgPatcher.kt new file mode 100644 index 0000000000000..acd3ab56022e5 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/patchers/SvgPatcher.kt @@ -0,0 +1,123 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.patchers + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +@Serializable +public class SvgPatcher( + val operations: List, + val filteredOperations: List +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SvgPatcher + + if (operations != other.operations) return false + if (filteredOperations != other.filteredOperations) return false + + return true + } + + override fun hashCode(): Int { + var result = operations.hashCode() + result = 31 * result + filteredOperations.hashCode() + return result + } + + override fun toString(): String { + return "SvgPatcher(operations=$operations, filteredOperations=$filteredOperations)" + } + +} + +@ApiStatus.Experimental +infix fun SvgPatcher?.combineWith(other: SvgPatcher?): SvgPatcher? { + if (this == null && other == null) return null + if (this == null) return other!! + if (other == null) return this + return SvgPatcher(operations + other.operations, filteredOperations + other.filteredOperations) +} + +@ApiStatus.Experimental +@Serializable +public class SvgPathFilteredOperations( + val path: String, + val operations: List +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SvgPathFilteredOperations + + if (path != other.path) return false + if (operations != other.operations) return false + + return true + } + + override fun hashCode(): Int { + var result = path.hashCode() + result = 31 * result + operations.hashCode() + return result + } + + override fun toString(): String { + return "SvgPathFilteredOperations(path='$path', operations=$operations)" + } + +} + +@ApiStatus.Experimental +@Serializable +public class SvgPatchOperation( + val attributeName: String, + val value: String?, + val conditional: Boolean, + val negatedCondition: Boolean, + val expectedValue: String?, + val operation: Operation +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SvgPatchOperation + + if (conditional != other.conditional) return false + if (negatedCondition != other.negatedCondition) return false + if (attributeName != other.attributeName) return false + if (value != other.value) return false + if (expectedValue != other.expectedValue) return false + if (operation != other.operation) return false + + return true + } + + override fun hashCode(): Int { + var result = conditional.hashCode() + result = 31 * result + negatedCondition.hashCode() + result = 31 * result + attributeName.hashCode() + result = 31 * result + (value.hashCode() ?: 0) + result = 31 * result + (expectedValue.hashCode() ?: 0) + result = 31 * result + operation.hashCode() + return result + } + + override fun toString(): String { + return "SvgPatchOperation(attributeName='$attributeName', value=$value, conditional=$conditional, negatedCondition=$negatedCondition, expectedValue=$expectedValue, operation=$operation)" + } + + @Serializable + enum class Operation { + Add, + Replace, + Remove, + Set + } + +} diff --git a/platform/icons-api/src/org/jetbrains/icons/swing/SwingIcon.kt b/platform/icons-api/src/org/jetbrains/icons/swing/SwingIcon.kt new file mode 100644 index 0000000000000..3bdaaff10ee16 --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/swing/SwingIcon.kt @@ -0,0 +1,36 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.swing + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.Icon +import org.jetbrains.icons.IconManager +import org.jetbrains.icons.design.IconDesigner +import org.jetbrains.icons.icon +import org.jetbrains.icons.modifiers.IconModifier + +/** + * Layer for embedding swing icons. + */ +@ApiStatus.Experimental +fun IconDesigner.swingIcon(legacyIcon: javax.swing.Icon, modifier: IconModifier = IconModifier) { + custom(SwingIconLayer(legacyIcon, modifier)) +} + +/** + * Shorthand for creating a new icon from swing icon, uses IconDesigner.swingIcon(). + */ +@ApiStatus.Experimental +fun javax.swing.Icon.toNewIcon(modifier: IconModifier = IconModifier): Icon { + return icon { + swingIcon(this@toNewIcon, modifier) + } +} + +/** + * Converts specific Icon to swing Icon. + * ! This is an expensive operation and can include image loading, reuse the instance if possible. ! + */ +@ApiStatus.Experimental +fun Icon.toSwingIcon(): javax.swing.Icon { + return IconManager.getInstance().toSwingIcon(this) +} \ No newline at end of file diff --git a/platform/icons-api/src/org/jetbrains/icons/swing/SwingIconLayer.kt b/platform/icons-api/src/org/jetbrains/icons/swing/SwingIconLayer.kt new file mode 100644 index 0000000000000..a52253294f04f --- /dev/null +++ b/platform/icons-api/src/org/jetbrains/icons/swing/SwingIconLayer.kt @@ -0,0 +1,15 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.swing + +import kotlinx.serialization.Serializable +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.modifiers.IconModifier +import javax.swing.Icon + +@ApiStatus.Experimental +@Serializable +class SwingIconLayer( + val legacyIcon: Icon, + override val modifier: IconModifier +) : IconLayer \ No newline at end of file diff --git a/platform/icons-impl/.gitignore b/platform/icons-impl/.gitignore new file mode 100644 index 0000000000000..af272e31f1de3 --- /dev/null +++ b/platform/icons-impl/.gitignore @@ -0,0 +1,111 @@ +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +### Gradle template +.gradle +build/ + +### Terraform template +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +*.ipr +*.iws +.idea/* +out/ +local.properties + +# IDEA/Android Studio project settings ignore exceptions +!.idea/codeStyles/ +!.idea/copyright/ +!.idea/dataSources.xml +!.idea/detekt.xml +!.idea/encodings.xml +!.idea/externalDependencies.xml +!.idea/fileTemplates/ +!.idea/icon.svg +!.idea/icon.png +!.idea/icon_dark.png +!.idea/inspectionProfiles/ +!.idea/ktfmt.xml +!.idea/ktlint.xml +!.idea/ktlint-plugin.xml +!.idea/runConfigurations/ +!.idea/scopes/ +!.idea/vcs.xml + +### Kotlin template +# Compiled class file +*.class + +# Log file +*.log + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +### Windows template +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### Misc + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Ignore IJP temp folder +/.intellijPlatform + +# Ignore release patch generator output +/this-release.txt + +# Ignore Kotlin compiler sessions +/.kotlin +/buildSrc/.kotlin +/foundation/bin/* +/samples/showcase/bin/* +/new_release_notes.md diff --git a/platform/icons-impl/BUILD.bazel b/platform/icons-impl/BUILD.bazel new file mode 100644 index 0000000000000..fd2dbda804948 --- /dev/null +++ b/platform/icons-impl/BUILD.bazel @@ -0,0 +1,44 @@ +### auto-generated section `build intellij.platform.icons.impl` start +load("@rules_jvm//:jvm.bzl", "jvm_library") + +jvm_library( + name = "icons-impl", + module_name = "intellij.platform.icons.impl", + visibility = ["//visibility:public"], + srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True), + deps = [ + "@lib//:kotlin-stdlib", + "//libraries/kotlinx/coroutines/core", + "//platform/icons-api", + "//platform/icons-api/rendering", + "//libraries/kotlinx/serialization/core", + "@lib//:jetbrains-annotations", + ], + exports = [ + "//platform/icons-api", + "//platform/icons-api/rendering", + ] +) + +jvm_library( + name = "icons-impl_test_lib", + module_name = "intellij.platform.icons.impl", + visibility = ["//visibility:public"], + srcs = glob([], allow_empty = True), + exports = [ + "//platform/icons-api", + "//platform/icons-api:icons-api_test_lib", + "//platform/icons-api/rendering", + "//platform/icons-api/rendering:rendering_test_lib", + ], + runtime_deps = [ + ":icons-impl", + "//libraries/kotlinx/coroutines/core:core_test_lib", + "//platform/icons-api:icons-api_test_lib", + "//platform/icons-api/rendering:rendering_test_lib", + "//platform/icons", + "//platform/icons:icons_test_lib", + "//libraries/kotlinx/serialization/core:core_test_lib", + ] +) +### auto-generated section `build intellij.platform.icons.impl` end \ No newline at end of file diff --git a/platform/icons-impl/README.md b/platform/icons-impl/README.md new file mode 100644 index 0000000000000..bf3d86a460a24 --- /dev/null +++ b/platform/icons-impl/README.md @@ -0,0 +1,3 @@ +## Structure +- root - General implementation, reused +- intellij - IntelliJ specific implementations \ No newline at end of file diff --git a/platform/icons-impl/build.gradle.kts b/platform/icons-impl/build.gradle.kts new file mode 100644 index 0000000000000..b1306f2897fe1 --- /dev/null +++ b/platform/icons-impl/build.gradle.kts @@ -0,0 +1,28 @@ +// This file is used by Jewel gradle script, check community/platform/jewel + +plugins { + jewel + alias(libs.plugins.kotlinx.serialization) +} + +sourceSets { + main { + kotlin { + setSrcDirs(listOf("src")) + } + } + + test { + kotlin { + setSrcDirs(listOf("test")) + } + } +} + +dependencies { + api("org.jetbrains:annotations:26.0.2") + api(project(":jb-icons-api")) + api(project(":jb-icons-api-rendering")) + api(libs.kotlinx.serialization.core) + api(libs.kotlinx.coroutines.core) +} diff --git a/platform/icons-impl/intellij.platform.icons.impl.iml b/platform/icons-impl/intellij.platform.icons.impl.iml new file mode 100644 index 0000000000000..f8d8181fe53d2 --- /dev/null +++ b/platform/icons-impl/intellij.platform.icons.impl.iml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + $KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/platform/icons-impl/intellij/BUILD.bazel b/platform/icons-impl/intellij/BUILD.bazel new file mode 100644 index 0000000000000..22f96bae67184 --- /dev/null +++ b/platform/icons-impl/intellij/BUILD.bazel @@ -0,0 +1,90 @@ +### auto-generated section `build intellij.platform.icons.impl.intellij` start +load("@rules_jvm//:jvm.bzl", "jvm_library", "jvm_provided_library", "resourcegroup") + +resourcegroup( + name = "intellij_resources", + srcs = glob(["resources/**/*"]), + strip_prefix = "resources" +) + +jvm_provided_library( + name = "platform_icons-api_provided", + lib = "//platform/icons-api" +) + +jvm_provided_library( + name = "platform_icons-api_rendering_provided", + lib = "//platform/icons-api/rendering" +) + +jvm_provided_library( + name = "platform_icons-impl_provided", + lib = "//platform/icons-impl" +) + +jvm_library( + name = "intellij", + module_name = "intellij.platform.icons.impl.intellij", + visibility = ["//visibility:public"], + srcs = glob(["src/**/*.kt", "src/**/*.java", "src/**/*.form"], allow_empty = True), + resources = [":intellij_resources"], + deps = [ + "@lib//:kotlin-stdlib", + "//libraries/kotlinx/coroutines/core", + "//libraries/kotlinx/serialization/core", + "//platform/util", + "//platform/util:util-ui", + "//platform/core-api:core", + "//platform/platform-impl/rpc", + "//fleet/util/core", + "//platform/core-impl", + "//platform/platform-impl:ide-impl", + "//platform/core-ui", + "@lib//:jetbrains-annotations", + "//platform/util/concurrency", + ":platform_icons-api_provided", + ":platform_icons-api_rendering_provided", + ":platform_icons-impl_provided", + ], + exports = [ + "//platform/icons-api", + "//platform/icons-api/rendering", + "//platform/icons-impl", + ] +) + +jvm_library( + name = "intellij_test_lib", + module_name = "intellij.platform.icons.impl.intellij", + visibility = ["//visibility:public"], + srcs = glob([], allow_empty = True), + exports = [ + "//platform/icons-api", + "//platform/icons-api:icons-api_test_lib", + "//platform/icons-api/rendering", + "//platform/icons-api/rendering:rendering_test_lib", + "//platform/icons-impl", + "//platform/icons-impl:icons-impl_test_lib", + ], + runtime_deps = [ + ":intellij", + "//libraries/kotlinx/coroutines/core:core_test_lib", + "//libraries/kotlinx/serialization/core:core_test_lib", + "//platform/icons-api", + "//platform/icons-api:icons-api_test_lib", + "//platform/icons-api/rendering", + "//platform/icons-api/rendering:rendering_test_lib", + "//platform/icons-impl", + "//platform/icons-impl:icons-impl_test_lib", + "//platform/util:util_test_lib", + "//platform/util:util-ui_test_lib", + "//platform/core-api:core_test_lib", + "//platform/platform-impl/rpc:rpc_test_lib", + "//fleet/util/core:core_test_lib", + "//platform/core-impl:core-impl_test_lib", + "//platform/platform-impl:ide-impl_test_lib", + "//platform/core-ui:core-ui_test_lib", + "//platform/util/concurrency:concurrency_test_lib", + ] +) +### auto-generated section `build intellij.platform.icons.impl.intellij` end \ No newline at end of file diff --git a/platform/icons-impl/intellij/intellij.platform.icons.impl.intellij.iml b/platform/icons-impl/intellij/intellij.platform.icons.impl.intellij.iml new file mode 100644 index 0000000000000..2e4055576f9e2 --- /dev/null +++ b/platform/icons-impl/intellij/intellij.platform.icons.impl.intellij.iml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + $KOTLIN_BUNDLED$/lib/kotlinx-serialization-compiler-plugin.jar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/platform/icons-impl/intellij/module-content.yaml b/platform/icons-impl/intellij/module-content.yaml new file mode 100644 index 0000000000000..3f0be96e40100 --- /dev/null +++ b/platform/icons-impl/intellij/module-content.yaml @@ -0,0 +1,3 @@ +- name: dist.all/lib/intellij.platform.icons.impl.intellij.jar + modules: + - name: intellij.platform.icons.impl.intellij \ No newline at end of file diff --git a/platform/icons-impl/intellij/resources/intellij.platform.icons.impl.intellij.xml b/platform/icons-impl/intellij/resources/intellij.platform.icons.impl.intellij.xml new file mode 100644 index 0000000000000..64d37ff3a6822 --- /dev/null +++ b/platform/icons-impl/intellij/resources/intellij.platform.icons.impl.intellij.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/IntelliJDeferredIconResolverService.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/IntelliJDeferredIconResolverService.kt new file mode 100644 index 0000000000000..17c9a2180d915 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/IntelliJDeferredIconResolverService.kt @@ -0,0 +1,29 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij + +import com.intellij.ide.PowerSaveMode +import com.intellij.openapi.components.Service +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.DeferredIcon +import org.jetbrains.icons.Icon +import org.jetbrains.icons.IconIdentifier +import org.jetbrains.icons.impl.DefaultDeferredIcon +import org.jetbrains.icons.impl.DeferredIconResolver +import org.jetbrains.icons.impl.DeferredIconResolverService +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.seconds + +@ApiStatus.Internal +@Service(Service.Level.APP) +class IntelliJDeferredIconResolverService(scope: CoroutineScope): DeferredIconResolverService(scope) { + override fun scheduleEvaluation(icon: DeferredIcon) { + if (!PowerSaveMode.isEnabled()) { + super.scheduleEvaluation(icon) + } + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/IntelliJIconManager.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/IntelliJIconManager.kt new file mode 100644 index 0000000000000..f25a296979c0b --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/IntelliJIconManager.kt @@ -0,0 +1,123 @@ +// 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.icons.impl.intellij + +import com.intellij.ide.plugins.cl.PluginAwareClassLoader +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.UI +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.service +import com.intellij.util.messages.Topic +import com.intellij.util.messages.Topic.BroadcastDirection +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.modules.SerializersModuleBuilder +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.Icon +import org.jetbrains.icons.IconIdentifier +import org.jetbrains.icons.ImageResourceLocation +import org.jetbrains.icons.design.IconDesigner +import org.jetbrains.icons.impl.DefaultDeferredIcon +import org.jetbrains.icons.impl.DefaultIconManager +import org.jetbrains.icons.impl.DeferredIconResolver +import org.jetbrains.icons.impl.intellij.custom.CustomIconLayerRegistration +import org.jetbrains.icons.impl.intellij.custom.CustomLegacyIconSerializer +import org.jetbrains.icons.impl.intellij.design.IntelliJIconDesigner +import org.jetbrains.icons.impl.intellij.rendering.IntelliJIconRendererManager +import org.jetbrains.icons.impl.intellij.rendering.SwingIcon +import org.jetbrains.icons.impl.intellij.rendering.images.IntelliJImageResourceProvider +import org.jetbrains.icons.rendering.IconRendererManager +import org.jetbrains.icons.rendering.ImageResourceProvider +import java.lang.ref.WeakReference +import kotlin.getValue + +class IntelliJIconManager : DefaultIconManager() { + override val resolverService: IntelliJDeferredIconResolverService by lazy { + service() + } + + override fun generateDeferredIconIdentifier(id: String?, classLoader: ClassLoader?): IconIdentifier { + if (classLoader != null) { + val (pluginId, moduleId) = getPluginAndModuleId(classLoader) + return ModuleIconIdentifier(pluginId, moduleId, super.generateDeferredIconIdentifier(id, classLoader)) + } + return super.generateDeferredIconIdentifier(id, classLoader) + } + + override fun createDeferredIconResolver( + id: IconIdentifier, + ref: WeakReference, + evaluator: (suspend () -> Icon)? + ): DeferredIconResolver { + if (evaluator == null) { + throw NotImplementedError("Remote Icon evaluation is not supported") + } + else { + return super.createDeferredIconResolver(id, ref, evaluator) + } + } + + override fun icon(designer: IconDesigner.() -> Unit): Icon { + val ijIconDesigner = IntelliJIconDesigner() + ijIconDesigner.designer() + return ijIconDesigner.build() + } + + override fun toSwingIcon(icon: Icon): javax.swing.Icon { + return SwingIcon(icon) + } + + override fun markDeferredIconUnused(id: IconIdentifier) { + // TODO delete unused deferred icons + } + + override suspend fun sendDeferredNotifications(id: IconIdentifier, result: Icon) { + val deferredIconListener = ApplicationManager.getApplication().messageBus.syncPublisher(DeferredIconListener.TOPIC) + withContext(Dispatchers.UI + ModalityState.any().asContextElement()) { + deferredIconListener.evaluated(id, result) + } + } + + override fun SerializersModuleBuilder.buildCustomSerializers() { + CustomLegacyIconSerializer.registerSerializersTo(this) + CustomIconLayerRegistration.registerSerializersTo(this) + polymorphic( + ImageResourceLocation::class, + ModuleImageResourceLocation::class, + ModuleImageResourceLocation.serializer() + ) + polymorphic( + IconIdentifier::class, + ModuleIconIdentifier::class, + ModuleIconIdentifier.serializer() + ) + } + + companion object { + fun activate() { + org.jetbrains.icons.IconManager.activate(IntelliJIconManager()) + IconRendererManager.activate(IntelliJIconRendererManager()) + ImageResourceProvider.activate(IntelliJImageResourceProvider()) + } + + internal fun getPluginAndModuleId(classLoader: ClassLoader): Pair { + if (classLoader is PluginAwareClassLoader) { + return classLoader.pluginId.idString to classLoader.moduleId + } + else { + return "com.intellij" to null + } + } + } +} + +@ApiStatus.Internal +interface DeferredIconListener { + fun evaluated(id: IconIdentifier, result: Icon) + + companion object { + @JvmField + @Topic.AppLevel + val TOPIC: Topic = Topic(DeferredIconListener::class.java, BroadcastDirection.NONE) + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/ModuleIconIdentifier.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/ModuleIconIdentifier.kt new file mode 100644 index 0000000000000..be17a3bd05756 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/ModuleIconIdentifier.kt @@ -0,0 +1,36 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij + +import kotlinx.serialization.Serializable +import org.jetbrains.icons.IconIdentifier + +@Serializable +class ModuleIconIdentifier( + val pluginId: String, + val moduleId: String?, + val uniqueId: IconIdentifier +): IconIdentifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ModuleIconIdentifier + + if (pluginId != other.pluginId) return false + if (moduleId != other.moduleId) return false + if (uniqueId != other.uniqueId) return false + + return true + } + + override fun hashCode(): Int { + var result = pluginId.hashCode() + result = 31 * result + moduleId.hashCode() + result = 31 * result + uniqueId.hashCode() + return result + } + + override fun toString(): String { + return "ModuleIconIdentifier(pluginId='$pluginId', moduleId='$moduleId', uniqueId='$uniqueId')" + } +} diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/ModuleImageResourceLocation.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/ModuleImageResourceLocation.kt new file mode 100644 index 0000000000000..68c4133ceb06c --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/ModuleImageResourceLocation.kt @@ -0,0 +1,45 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij + +import kotlinx.serialization.Serializable +import org.jetbrains.icons.ImageResourceLocation + +@Serializable +class ModuleImageResourceLocation( + @JvmField val path: String, + @JvmField val pluginId: String, + @JvmField val moduleId: String?, +): ImageResourceLocation { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ModuleImageResourceLocation + + if (path != other.path) return false + if (pluginId != other.pluginId) return false + if (moduleId != other.moduleId) return false + + return true + } + + override fun hashCode(): Int { + var result = path.hashCode() + result = 31 * result + pluginId.hashCode() + result = 31 * result + (moduleId?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "ModuleImageResourceLoader(path='$path', pluginId='$pluginId', moduleId=$moduleId)" + } + + companion object { + fun fromClassLoader(path: String, classLoader: ClassLoader): ImageResourceLocation { + val (pluginId, moduleId) = IntelliJIconManager.getPluginAndModuleId(classLoader) + return ModuleImageResourceLocation(path, pluginId, moduleId) + } + + } + +} diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/custom/CustomIconLayerRegistration.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/custom/CustomIconLayerRegistration.kt new file mode 100644 index 0000000000000..65e4fa4808aa6 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/custom/CustomIconLayerRegistration.kt @@ -0,0 +1,16 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.custom + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.layers.IconLayer +import kotlin.reflect.KClass + +abstract class CustomIconLayerRegistration( + klass: KClass +): CustomSerializableRegistration(klass) { + @ApiStatus.Internal + companion object: CustomSerializableRegistration.Companion>( + IconLayer ::class, + "com.intellij.icons.customLayer" + ) +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/custom/CustomLegacyIconSerializer.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/custom/CustomLegacyIconSerializer.kt new file mode 100644 index 0000000000000..2d052b64ed862 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/custom/CustomLegacyIconSerializer.kt @@ -0,0 +1,16 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.custom + +import org.jetbrains.annotations.ApiStatus +import javax.swing.Icon +import kotlin.reflect.KClass + +abstract class CustomLegacyIconSerializer( + klass: KClass +): CustomSerializableRegistration(klass) { + @ApiStatus.Internal + companion object: CustomSerializableRegistration.Companion>( + Icon::class, + "com.intellij.icons.customLegacyIconSerializer" + ) +} diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/custom/CustomSerializableRegistration.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/custom/CustomSerializableRegistration.kt new file mode 100644 index 0000000000000..19eb0f81441a6 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/custom/CustomSerializableRegistration.kt @@ -0,0 +1,41 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.custom + +import com.intellij.openapi.extensions.ExtensionPointName +import kotlinx.serialization.KSerializer +import kotlinx.serialization.modules.SerializersModuleBuilder +import kotlin.reflect.KClass + +abstract class CustomSerializableRegistration( + internal val klass: KClass +) { + abstract fun createSerializer(): KSerializer + + abstract class Companion>( + private val baseClass: KClass, + extensionName: String + ) { + fun registerSerializersTo(builder: SerializersModuleBuilder) { + EP_NAME.extensionList.forEach { instance -> + val serializer = instance.createSerializer() + @Suppress("UNCHECKED_CAST") + builder.addCustom(instance as CustomSerializableRegistration) + onRegistration(instance, serializer) + } + } + + private fun SerializersModuleBuilder.addCustom(instance: CustomSerializableRegistration) { + polymorphic( + baseClass, + instance.klass, + instance.createSerializer() + ) + } + + open fun onRegistration(ep: TEP, serializer: KSerializer<*>) { + // Do nothing in default implementation + } + + val EP_NAME: ExtensionPointName = ExtensionPointName(extensionName) + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/design/IntelliJIconDesigner.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/design/IntelliJIconDesigner.kt new file mode 100644 index 0000000000000..1230183d85db6 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/design/IntelliJIconDesigner.kt @@ -0,0 +1,17 @@ +// 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.icons.impl.intellij.design + +import org.jetbrains.icons.modifiers.IconModifier +import org.jetbrains.icons.impl.design.DefaultIconDesigner +import org.jetbrains.icons.impl.intellij.ModuleImageResourceLocation + +class IntelliJIconDesigner: DefaultIconDesigner() { + override fun image(path: String, classLoader: ClassLoader?, modifier: IconModifier) { + if (classLoader == null) error("Specifying classloader for icon image is required in IntelliJ.") + image(ModuleImageResourceLocation.fromClassLoader(path, classLoader), modifier) + } + + override fun createNestedDesigner(): DefaultIconDesigner { + return IntelliJIconDesigner() + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/Convertors.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/Convertors.kt new file mode 100644 index 0000000000000..c3c8faa66a7fd --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/Convertors.kt @@ -0,0 +1,107 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering + +import com.intellij.ui.svg.SvgAttributePatcher +import com.intellij.util.SVGLoader +import org.jetbrains.icons.design.BlendMode +import org.jetbrains.icons.filters.ColorFilter +import org.jetbrains.icons.filters.TintColorFilter +import org.jetbrains.icons.patchers.SvgPatchOperation +import org.jetbrains.icons.patchers.SvgPatcher +import java.awt.Color +import java.awt.image.RGBImageFilter + +internal fun ColorFilter.toAwtFilter(): RGBImageFilter { + return when (this) { + is TintColorFilter -> { + AwtColorFilter.fromColorAndBlendMode(color, blendMode) + } + } +} + +internal fun SvgPatcher.toIJPatcher(): SVGLoader.SvgElementColorPatcherProvider { + return ProxySvgPatcher(this) +} + +private class ProxySvgPatcher( + private val patcher: SvgPatcher +): SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher { + override fun attributeForPath(path: String): SvgAttributePatcher = this + override fun digest(): LongArray { + return longArrayOf(patcher.hashCode().toLong()) + } + + override fun patchColors(attributes: MutableMap) { + // TODO Support filtered operations - not possible with current IJ svg loader + for (operation in patcher.operations) { + when (operation.operation) { + SvgPatchOperation.Operation.Add -> { + if (!attributes.containsKey(operation.attributeName)) { + attributes[operation.attributeName] = operation.value!! + } + } + SvgPatchOperation.Operation.Replace -> { + if (operation.conditional) { + val matches = attributes[operation.attributeName] == operation.expectedValue + if (matches == !operation.negatedCondition) { + attributes.replace(operation.attributeName, operation.value!!) + } + } else { + attributes.replace(operation.attributeName, operation.value!!) + } + } + SvgPatchOperation.Operation.Remove -> { + if (operation.conditional) { + val matches = attributes[operation.attributeName] == operation.expectedValue + if (matches == !operation.negatedCondition) { + attributes.remove(operation.attributeName) + } + } else { + attributes.remove(operation.attributeName) + } + } + SvgPatchOperation.Operation.Set -> attributes[operation.attributeName] = operation.value!! + } + } + } +} + +private class AwtColorFilter(val color: Color, val keepGray: Boolean, val keepBrightness: Boolean) : RGBImageFilter() { + private val base = Color.RGBtoHSB(color.red, color.green, color.blue, null) + + override fun filterRGB(x: Int, y: Int, rgba: Int): Int { + val r = rgba shr 16 and 0xff + val g = rgba shr 8 and 0xff + val b = rgba and 0xff + val hsb = FloatArray(3) + Color.RGBtoHSB(r, g, b, hsb) + val rgb = Color.HSBtoRGB(base[0], + base[1] * if (keepGray) hsb[1] else 1.0f, + base[2] * if (keepBrightness) hsb[2] else 1.0f) + return rgba and -0x1000000 or (rgb and 0xffffff) + } + + companion object { + fun fromColorAndBlendMode(color: org.jetbrains.icons.design.Color, blendMode: BlendMode): AwtColorFilter { + var keepGray = true + var keepBrightness = true + when (blendMode) { + BlendMode.Hue -> { + keepGray = false + keepBrightness = false + } + BlendMode.Saturation -> { + keepGray = false + } + else -> { + // Do nothing + } + } + return AwtColorFilter( + color.toAwtColor(), + keepGray, + keepBrightness + ) + } + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/IconUpdateService.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/IconUpdateService.kt new file mode 100644 index 0000000000000..420877aa3ccb2 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/IconUpdateService.kt @@ -0,0 +1,29 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import org.jetbrains.annotations.ApiStatus + +@Service(Service.Level.APP) +@ApiStatus.Internal +class IconUpdateService(val scope: CoroutineScope) { + @ApiStatus.Experimental + fun scheduleDelayedUpdate(delay: Long, updateId: Int, flow: MutableSharedFlow, updateCallback: (Int) -> Unit, rateLimiter: () -> Boolean = { false }) { + scope.launch { + delay(delay) + if (rateLimiter()) return@launch + flow.emit(updateId) + updateCallback(updateId) + } + } + + companion object { + @JvmStatic + fun getInstance(): IconUpdateService = service() + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/ImageResourceLoaderExtension.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/ImageResourceLoaderExtension.kt new file mode 100644 index 0000000000000..9b0e753b39ada --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/ImageResourceLoaderExtension.kt @@ -0,0 +1,29 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering + +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.util.xmlb.annotations.Attribute +import org.jetbrains.icons.ImageResourceLocation +import org.jetbrains.icons.rendering.GenericImageResourceLoader + + +internal class ImageResourceLoaderExtension { + @Attribute("loader") + lateinit var loader: GenericImageResourceLoader + + @Attribute("location") + lateinit var location: String + + companion object { + fun getLoaderFor(location: ImageResourceLocation): GenericImageResourceLoader? { + for (extension in LOADER_EP_NAME.extensionList) { + if (location::class.qualifiedName == extension.location) { + return extension.loader + } + } + return null + } + + val LOADER_EP_NAME: ExtensionPointName = ExtensionPointName("com.intellij.icons.imageResourceLoader") + } +} diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/IntelliJIconRendererManager.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/IntelliJIconRendererManager.kt new file mode 100644 index 0000000000000..fa89fe1888725 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/IntelliJIconRendererManager.kt @@ -0,0 +1,50 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering + +import com.intellij.util.ui.StartupUiUtil +import kotlinx.coroutines.CoroutineScope +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.impl.intellij.rendering.custom.CustomIconLayerRendererProvider +import org.jetbrains.icons.rendering.ImageModifiers +import org.jetbrains.icons.rendering.MutableIconUpdateFlow +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.impl.rendering.CoroutineBasedMutableIconUpdateFlow +import org.jetbrains.icons.impl.rendering.DefaultIconRendererManager +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.impl.rendering.DefaultImageModifiers +import org.jetbrains.icons.impl.rendering.layers.IconLayerRenderer + +@Suppress("UNCHECKED_CAST") +class IntelliJIconRendererManager: DefaultIconRendererManager() { + override fun createRenderer(layer: IconLayer, renderingContext: RenderingContext): IconLayerRenderer { + val defaultRenderer = createRendererOrNull(layer, renderingContext) + if (defaultRenderer != null) return defaultRenderer + + return CustomIconLayerRendererProvider.createRendererFor(layer, renderingContext) + ?: error("No renderer found for Icon Layer type: $layer\nMake sure that the corresponding renderer is properly registered.") + } + + override fun createUpdateFlow(scope: CoroutineScope?, updateCallback: (Int) -> Unit): MutableIconUpdateFlow { + if (scope != null) { + return CoroutineBasedMutableIconUpdateFlow(scope, updateCallback) + } else { + return IntelliJMutableIconUpdateFlowImpl(updateCallback) + } + } + + override fun createRenderingContext( + updateFlow: MutableIconUpdateFlow, + defaultImageModifiers: ImageModifiers?, + ): RenderingContext { + val knownModifiers = defaultImageModifiers as? DefaultImageModifiers + return RenderingContext( + updateFlow, + DefaultImageModifiers( + defaultImageModifiers?.colorFilter, + defaultImageModifiers?.svgPatcher, + StartupUiUtil.isDarkTheme, + knownModifiers?.stroke + ) + ) + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/IntelliJMutableIconUpdateFlowImpl.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/IntelliJMutableIconUpdateFlowImpl.kt new file mode 100644 index 0000000000000..bf5d3d5cabce0 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/IntelliJMutableIconUpdateFlowImpl.kt @@ -0,0 +1,24 @@ +// 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.icons.impl.intellij.rendering + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import org.jetbrains.icons.Icon +import org.jetbrains.icons.impl.rendering.MutableIconUpdateFlowBase + +internal class IntelliJMutableIconUpdateFlowImpl( + updateCallback: (Int) -> Unit +) : MutableIconUpdateFlowBase(updateCallback) { + override fun MutableSharedFlow.emitDelayed(delay: Long, value: Int) { + IconUpdateService.getInstance().scheduleDelayedUpdate(delay, value, this@emitDelayed, updateCallback) { + handleRateLimiting() + } + } + + override fun collectDynamic(flow: Flow, handler: (Icon) -> Unit) { + IconUpdateService.getInstance().scope.launch { + flow.collect { handler(it) } + } + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingIcon.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingIcon.kt new file mode 100644 index 0000000000000..643453710cea6 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingIcon.kt @@ -0,0 +1,77 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.Icon +import org.jetbrains.icons.rendering.IconRendererManager +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.impl.rendering.layers.applyTo +import org.jetbrains.icons.rendering.Dimensions +import java.awt.Component +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.image.BufferedImage + +@ApiStatus.Experimental +class SwingIcon( + val icon: Icon +): javax.swing.Icon { + private val renderer by lazy { + IconRendererManager.getInstance().createRenderer(icon, RenderingContext.Empty) // TODO listen for updates to redraw Icon + } + private val dimensions by lazy { + renderer.calculateExpectedDimensions(SwingScalingContext(1f)) + } + + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + val scaling = getScaling(g) + val boundsSize = if (c != null) { + Dimensions(c.width, c.height) + } else Dimensions(dimensions.width, dimensions.height) + withLayer(scaling, boundsSize, g, x, y) { newGraphics -> + val swingApi = SwingPaintingApi( + c, + newGraphics, + 0, + 0, + scaling = scaling, + customWidth = boundsSize.width, + customHeight = boundsSize.height + ) + renderer.render(swingApi) + } + } + + private fun withLayer(scaling: ScalingContext, boundsSize: Dimensions, g: Graphics, x: Int, y: Int, painting: (Graphics2D) -> Unit) { + val w = scaling.applyTo(boundsSize.width - x) + val h = scaling.applyTo(boundsSize.height - y) + if (w < 0 || h < 0) return + val img = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) + val sublayer = img.createGraphics() + try { + painting(sublayer as Graphics2D) + g.drawImage(img, x, y, boundsSize.width, boundsSize.height, 0, 0, img.width, img.height, null) + } finally { + sublayer.dispose() + } + } + + private fun Graphics2D.clearTransform() { + scale(1 / transform.scaleX, 1 / transform.scaleY) + } + + private fun getScaling(g: Graphics?): SwingScalingContext { + if (g is Graphics2D) { + return SwingScalingContext(g.transform.scaleX.toFloat()) + } else return SwingScalingContext(1f) + } + + override fun getIconWidth(): Int { + return dimensions.width + } + + override fun getIconHeight(): Int { + return dimensions.height + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingIconLayerRegistration.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingIconLayerRegistration.kt new file mode 100644 index 0000000000000..96282a9995edd --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingIconLayerRegistration.kt @@ -0,0 +1,12 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering + +import kotlinx.serialization.KSerializer +import org.jetbrains.icons.impl.intellij.custom.CustomIconLayerRegistration +import org.jetbrains.icons.swing.SwingIconLayer + +internal class SwingIconLayerRegistration: CustomIconLayerRegistration(SwingIconLayer::class) { + override fun createSerializer(): KSerializer { + return SwingIconLayer.serializer() + } +} diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingIconLayerRenderer.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingIconLayerRenderer.kt new file mode 100644 index 0000000000000..d5b744dc4ad41 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingIconLayerRenderer.kt @@ -0,0 +1,110 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering + +import com.intellij.ide.PowerSaveMode +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.UI +import com.intellij.openapi.application.asContextElement +import com.intellij.ui.DeferredIcon +import com.intellij.ui.DeferredIconListener +import com.intellij.util.messages.impl.subscribeAsFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import org.jetbrains.icons.impl.intellij.rendering.custom.CustomIconLayerRendererProvider +import org.jetbrains.icons.impl.rendering.DefaultImageResourceProvider +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.ImageResource +import org.jetbrains.icons.rendering.ImageResourceProvider +import org.jetbrains.icons.impl.rendering.layers.BaseImageIconLayerRenderer +import org.jetbrains.icons.impl.rendering.layers.IconLayerRenderer +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.impl.rendering.layers.applyTo +import org.jetbrains.icons.impl.rendering.layers.generateImageModifiers +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.rendering.PaintingApi +import org.jetbrains.icons.swing.SwingIconLayer +import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.Icon +import kotlin.coroutines.resume + +class SwingIconLayerRendererProvider: CustomIconLayerRendererProvider { + override fun handles(layer: IconLayer): Boolean { + return layer is SwingIconLayer + } + + override fun createRenderer(layer: IconLayer, renderingContext: RenderingContext): IconLayerRenderer { + val swingLayer = layer as SwingIconLayer + val renderer = SwingIconLayerRenderer( + swingLayer, + renderingContext + ) + return renderer + } +} + +class SwingIconLayerRenderer( + override val layer: SwingIconLayer, + override val renderingContext: RenderingContext +): BaseImageIconLayerRenderer() { + private val deferredIcon = layer.legacyIcon as? DeferredIcon? + private var isDone = deferredIcon?.isDone ?: false + override var image: ImageResource = createImageResource() + private val isPending = AtomicBoolean(false) + + override fun render(api: PaintingApi) { + checkForUpdatesIfNeeded() + super.render(api) + } + + private fun checkForUpdatesIfNeeded() { + if (deferredIcon != null && !PowerSaveMode.isEnabled() && !isDone) { + if (!isPending.getAndSet(true)) { + if (!deferredIcon.isDone) { + IconUpdateService.getInstance().scope.launch { + withContext(Dispatchers.UI + ModalityState.any().asContextElement()) { + suspendUntilDeferredIconIsDone() + isDone = true + image = createImageResource() + } + } + } else { + isDone = true + image = createImageResource() + } + } + } + } + + private fun createImageResource(): ImageResource { + val provider = ImageResourceProvider.getInstance() as? DefaultImageResourceProvider + if (provider == null) error("Swing Icon fallback is only supported with DefaultImageResourceProvider") + return provider.fromSwingIcon(layer.legacyIcon, layer.generateImageModifiers(renderingContext)) + } + + override fun calculateExpectedDimensions(scaling: ScalingContext): Dimensions { + return Dimensions(scaling.applyTo(image.width) ?: 16, scaling.applyTo(image.height) ?: 16) + } + + private suspend fun suspendUntilDeferredIconIsDone() { + val connection = ApplicationManager.getApplication().messageBus.simpleConnect() + try { + suspendCancellableCoroutine { continuation -> + val listener = object : DeferredIconListener { + override fun evaluated(deferred: DeferredIcon, result: Icon) { + if (deferred === this@SwingIconLayerRenderer.deferredIcon) { + continuation.resume(Unit) + } + } + } + connection.subscribe(DeferredIconListener.TOPIC, listener) + } + } + finally { + connection.disconnect() + } + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingPaintingApi.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingPaintingApi.kt new file mode 100644 index 0000000000000..c02cd5da1dff0 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/SwingPaintingApi.kt @@ -0,0 +1,230 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering + +import org.jetbrains.icons.design.Color +import org.jetbrains.icons.filters.ColorFilter +import org.jetbrains.icons.impl.intellij.rendering.images.awtImage +import org.jetbrains.icons.rendering.BitmapImageResource +import org.jetbrains.icons.rendering.Bounds +import org.jetbrains.icons.rendering.DrawMode +import org.jetbrains.icons.rendering.FitAreaScale +import org.jetbrains.icons.rendering.ImageResource +import org.jetbrains.icons.rendering.PaintingApi +import org.jetbrains.icons.rendering.RescalableImageResource +import org.jetbrains.icons.rendering.ScalingContext +import java.awt.AlphaComposite +import java.awt.Component +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.Image +import java.awt.RenderingHints +import java.awt.Shape +import java.awt.geom.Ellipse2D +import java.awt.geom.Rectangle2D + +class SwingPaintingApi( + val c: Component?, + val g: Graphics, + val x: Int, + val y: Int, + private val customX: Int? = null, + private val customY: Int? = null, + private val customWidth: Int? = null, + private val customHeight: Int? = null, + private val overrideColorFilter: ColorFilter? = null, + override val scaling: ScalingContext = SwingScalingContext(1f), +) : PaintingApi { + override val bounds: Bounds + get() { + if (c == null) return Bounds(0, 0, customWidth ?: 0, customHeight ?: 0) + return Bounds( + customX ?: (x * scaling.display).toInt(), + customY ?: (y * scaling.display).toInt(), + customWidth ?: (c.width * scaling.display).toInt(), + customHeight ?: (c.height * scaling.display).toInt() + ) + } + + override fun getUsedBounds(): Bounds = bounds + + override fun withCustomContext(bounds: Bounds, overrideColorFilter: ColorFilter?): PaintingApi { + return SwingPaintingApi( + c, + g, + x, + y, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + overrideColorFilter ?: this.overrideColorFilter, + scaling + ) + } + + override fun drawCircle(color: Color, x: Int, y: Int, radius: Float, alpha: Float, mode: DrawMode) { + val r = radius.toDouble() + drawShape(color, Ellipse2D.Double(x - r, y - r, r + r, r + r), alpha, mode) + } + + override fun drawRect(color: Color, x: Int, y: Int, width: Int, height: Int, alpha: Float, mode: DrawMode) { + drawShape(color, Rectangle2D.Double(x.toDouble(), y.toDouble(), width.toDouble(), height.toDouble()), alpha, mode) + } + + private fun drawShape(color: Color, shape: Shape, alpha: Float, mode: DrawMode) { + setDrawingDefaults() + if (g is Graphics2D) { + val oldComposite = g.composite + val oldPaint = g.paint + try { + if (mode == DrawMode.Clear) { + g.composite = AlphaComposite.Clear + } + g.color = color.toAwtColor() + if (mode == DrawMode.Stroke) { + g.draw(shape) + } + else { + g.fill(shape) + } + } + finally { + g.composite = oldComposite + g.paint = oldPaint + } + } + } + + override fun drawImage( + image: ImageResource, + x: Int, + y: Int, + width: Int?, + height: Int?, + srcX: Int, + srcY: Int, + srcWidth: Int?, + srcHeight: Int?, + alpha: Float, + colorFilter: ColorFilter?, + ) { + when (image) { + is BitmapImageResource -> { + drawImage(image, x, y, width, height, srcX, srcY, srcWidth, srcHeight, alpha, colorFilter) + } + is RescalableImageResource -> { + drawImage(image, x, y, width, height, srcX, srcY, srcWidth, srcHeight, alpha, colorFilter) + } + } + } + + private fun setDrawingDefaults() { + if (g !is Graphics2D) return + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE) + } + + private fun drawImage( + image: RescalableImageResource, + x: Int, + y: Int, + width: Int?, + height: Int?, + srcX: Int, + srcY: Int, + srcWidth: Int?, + srcHeight: Int?, + alpha: Float, + colorFilter: ColorFilter?, + ) { + val swingImage = image.scale(FitAreaScale(width ?: bounds.width, height ?: bounds.height)) + drawImage(swingImage, x, y, width, height, srcX, srcY, srcWidth, srcHeight, alpha, colorFilter) + } + + private fun drawImage( + image: BitmapImageResource, + x: Int, + y: Int, + width: Int?, + height: Int?, + srcX: Int, + srcY: Int, + srcWidth: Int?, + srcHeight: Int?, + alpha: Float, + colorFilter: ColorFilter?, + ) { + val swingImage = image.awtImage() + + drawImage( + swingImage, + x, + y, + width, + height, + srcX, + srcY, + srcWidth, + srcHeight, + alpha, + colorFilter + ) + } + + private fun drawImage( + image: Image, + x: Int, + y: Int, + width: Int?, + height: Int?, + srcX: Int, + srcY: Int, + srcWidth: Int?, + srcHeight: Int?, + alpha: Float, + colorFilter: ColorFilter?, + ) { + // TODO apply alpha & color filters + + val imageWidth = image.getWidth(null) + val imageHeight = image.getHeight(null) + + if (imageWidth == 0 || imageHeight == 0) return + setDrawingDefaults() + g.drawImage( + image, + x, + y, + x + (width ?: imageWidth), + y + (height ?: imageHeight), + srcX, + srcY, + srcX + (srcWidth ?: imageWidth), + srcY + (srcHeight ?: imageHeight), + null, + ) + } +} + +fun PaintingApi.swing(): SwingPaintingApi? = this as? SwingPaintingApi + +internal class SwingScalingContext( + override val display: Float, +) : ScalingContext { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SwingScalingContext + + return display == other.display + } + + override fun hashCode(): Int { + return display.hashCode() + } + + override fun toString(): String { + return "SwingScalingContext(display=$display)" + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/custom/CustomIconLayerRendererProvider.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/custom/CustomIconLayerRendererProvider.kt new file mode 100644 index 0000000000000..49f3f995dc067 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/custom/CustomIconLayerRendererProvider.kt @@ -0,0 +1,25 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering.custom + +import com.intellij.openapi.extensions.ExtensionPointName +import org.jetbrains.icons.impl.intellij.rendering.SwingIconLayerRenderer +import org.jetbrains.icons.impl.rendering.layers.IconLayerRenderer +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.swing.SwingIconLayer +import org.jetbrains.icons.rendering.RenderingContext + +interface CustomIconLayerRendererProvider { + fun handles(layer: IconLayer): Boolean + fun createRenderer(layer: IconLayer, renderingContext: RenderingContext): IconLayerRenderer + + companion object { + fun createRendererFor(layer: IconLayer, renderingContext: RenderingContext): IconLayerRenderer? { + for (extension in EP_NAME.extensionList) { + if (extension.handles(layer)) return SwingIconLayerRenderer(layer as SwingIconLayer, renderingContext) + } + return null + } + + val EP_NAME: ExtensionPointName = ExtensionPointName("com.intellij.icons.customLayerRendererProvider") + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/AwtImageResource.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/AwtImageResource.kt new file mode 100644 index 0000000000000..24b4bc5b50b32 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/AwtImageResource.kt @@ -0,0 +1,85 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering.images + +import org.jetbrains.icons.impl.rendering.CachedGPUImageResourceHolder +import org.jetbrains.icons.rendering.BitmapImageResource +import org.jetbrains.icons.rendering.lowlevel.GPUImageResourceHolder +import java.awt.Image +import java.awt.Transparency +import java.awt.color.ColorSpace +import java.awt.image.BufferedImage +import java.awt.image.ComponentColorModel +import java.awt.image.DataBuffer +import java.awt.image.Raster + +class AwtImageResource( + val image: Image +) : CachedGPUImageResourceHolder(), BitmapImageResource { + private val bufferedIamge by lazy { + if (image is BufferedImage) return@lazy image + val bi = BufferedImage( + image.getWidth(null), + image.getHeight(null), + BufferedImage.TYPE_INT_ARGB + ) + val g = bi.createGraphics() + g.drawImage(image, 0, 0, null) + g.dispose() + return@lazy bi + } + + override fun getRGBPixels(): IntArray { + return bufferedIamge.getRGB(0, 0, bufferedIamge.raster.width, bufferedIamge.raster.height, null, 0, bufferedIamge.raster.width) + } + + override fun readPrefetchedPixel(pixels: IntArray, x: Int, y: Int): Int? { + return pixels.getOrNull(y * bufferedIamge.raster.width + x) + } + + override fun getBandOffsetsToSRGB(): IntArray { + return intArrayOf(0, 1, 2, 3) + } + + override val width: Int = image.getWidth(null) + override val height: Int = image.getHeight(null) +} + +fun BitmapImageResource.awtImage(): Image { + if (this is AwtImageResource) return image + val cache = (this as? GPUImageResourceHolder) + return cache?.getOrGenerateBitmap(Image::class) { + awtImageWithoutCaching() + } ?: awtImageWithoutCaching() +} + +private fun BitmapImageResource.awtImageWithoutCaching(): Image { + val pxs = getRGBPixels() + val order = getBandOffsetsToSRGB() + val raster = Raster.createInterleavedRaster( + DirectDataBuffer(pxs), + this.width, + this.height, + this.width * 4, + 4, + order, + null + ) + val colorModel = ComponentColorModel( + ColorSpace.getInstance(ColorSpace.CS_sRGB), + true, + false, + Transparency.TRANSLUCENT, + DataBuffer.TYPE_BYTE + ) + return BufferedImage(colorModel, raster!!, false, null) +} + +private class DirectDataBuffer(val pixels: IntArray) : DataBuffer(TYPE_BYTE, pixels.size) { + override fun getElem(bank: Int, index: Int): Int { + return pixels[index] + } + + override fun setElem(bank: Int, index: Int, value: Int) { + throw UnsupportedOperationException("no write access") + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/DataLoaderImageResourceHolder.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/DataLoaderImageResourceHolder.kt new file mode 100644 index 0000000000000..7f976592319cc --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/DataLoaderImageResourceHolder.kt @@ -0,0 +1,27 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering.images + +import com.intellij.ui.icons.ImageDataLoader +import com.intellij.ui.scale.ScaleContext +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.ImageModifiers +import java.awt.Image + +internal class DataLoaderImageResourceHolder( + val dataLoader: ImageDataLoader +) : SwingImageResourceHolder { + override fun getImage( + scale: ScaleContext, + imageModifiers: ImageModifiers?, + ): Image? { + return dataLoader.loadImage( + parameters = imageModifiers.toLoadParameters(), + scaleContext = scale + ) + } + + override fun getExpectedDimensions(): Dimensions { + val img = getImage(ScaleContext.create(), null) ?: return Dimensions(0, 0) + return Dimensions(img.getWidth(null), img.getHeight(null)) + } +} diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/IntelliJImageResource.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/IntelliJImageResource.kt new file mode 100644 index 0000000000000..6f0c39e21990f --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/IntelliJImageResource.kt @@ -0,0 +1,40 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering.images + +import com.intellij.ui.scale.ScaleContext +import com.intellij.ui.scale.ScaleType +import com.intellij.util.JBHiDPIScaledImage +import org.jetbrains.icons.impl.rendering.CachedGPUImageResourceHolder +import org.jetbrains.icons.rendering.BitmapImageResource +import org.jetbrains.icons.rendering.Bounds +import org.jetbrains.icons.rendering.EmptyBitmapImageResource +import org.jetbrains.icons.rendering.ImageModifiers +import org.jetbrains.icons.rendering.ImageScale +import org.jetbrains.icons.rendering.RescalableImageResource + +internal class IntelliJImageResource( + private val holder: SwingImageResourceHolder, + private val imageModifiers: ImageModifiers? = null +): RescalableImageResource, CachedGPUImageResourceHolder() { + private val expectedDimensions by lazy { holder.getExpectedDimensions() } + + override fun scale(scale: ImageScale): BitmapImageResource { + val objScale = scale.calculateScalingFactorByOriginalDimensions(width, height) + val scaleContext = ScaleContext.of(arrayOf( + ScaleType.OBJ_SCALE.of(objScale), + ScaleType.USR_SCALE.of(1.0), + ScaleType.SYS_SCALE.of(1.0), + )) + val rawImage = holder.getImage(scaleContext, imageModifiers) ?: return EmptyBitmapImageResource + val awtImage = (rawImage as? JBHiDPIScaledImage)?.delegate ?: rawImage + return AwtImageResource(awtImage) + } + + override fun calculateExpectedDimensions(scale: ImageScale): Bounds { + val ijScale = scale.calculateScalingFactorByOriginalDimensions(width, height) + return Bounds(0, 0, width = (width * ijScale).toInt(), height = (height * ijScale).toInt()) + } + + override val width: Int by lazy { expectedDimensions.width } + override val height: Int by lazy { expectedDimensions.height } +} diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/IntelliJImageResourceProvider.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/IntelliJImageResourceProvider.kt new file mode 100644 index 0000000000000..003831782addb --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/IntelliJImageResourceProvider.kt @@ -0,0 +1,26 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering.images + +import org.jetbrains.icons.rendering.ImageModifiers +import org.jetbrains.icons.rendering.ImageResource +import org.jetbrains.icons.ImageResourceLocation +import org.jetbrains.icons.impl.intellij.ModuleImageResourceLocation +import org.jetbrains.icons.impl.intellij.rendering.ImageResourceLoaderExtension +import org.jetbrains.icons.impl.rendering.DefaultImageResourceProvider +import javax.swing.Icon + +class IntelliJImageResourceProvider: DefaultImageResourceProvider() { + override fun loadImage(location: ImageResourceLocation, imageModifiers: ImageModifiers?): ImageResource { + val loader = if (location is ModuleImageResourceLocation) { + ModuleImageResourceLoader() + } else { + ImageResourceLoaderExtension.getLoaderFor(location) + } + if (loader == null) error("Cannot find loader for location: $location") + return loader.loadGenericImage(location, imageModifiers) ?: error("Cannot load image for location: $location") + } + + override fun fromSwingIcon(icon: Icon, imageModifiers: ImageModifiers?): ImageResource { + return IntelliJImageResource(LegacyIconImageResourceHolder(icon), imageModifiers) + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/LegacyIconImageResourceHolder.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/LegacyIconImageResourceHolder.kt new file mode 100644 index 0000000000000..86e23d39b2d1b --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/LegacyIconImageResourceHolder.kt @@ -0,0 +1,34 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering.images + +import com.intellij.openapi.util.IconLoader +import com.intellij.ui.icons.CachedImageIcon +import com.intellij.ui.icons.RgbImageFilterSupplier +import com.intellij.ui.scale.ScaleContext +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.ImageModifiers +import java.awt.Image +import java.awt.image.RGBImageFilter +import javax.swing.Icon + +internal class LegacyIconImageResourceHolder( + private val backingLegacyIcon: Icon +): SwingImageResourceHolder { + override fun getExpectedDimensions(): Dimensions { + return Dimensions(backingLegacyIcon.iconWidth, backingLegacyIcon.iconHeight) + } + + override fun getImage(scale: ScaleContext, imageModifiers: ImageModifiers?): Image? { + val params = imageModifiers.toLoadParameters() + val svgPatcher = params.colorPatcher + val icon = if (backingLegacyIcon is CachedImageIcon && svgPatcher != null) { + backingLegacyIcon.createWithPatcher(svgPatcher, isDark = params.isDark, useStroke = params.isStroke) + } else backingLegacyIcon + val filtered = if (params.filters.isNotEmpty()) { + IconLoader.filterIcon(icon = icon, filterSupplier = object : RgbImageFilterSupplier { + override fun getFilter() = params.filters.first() as RGBImageFilter + }) + } else icon + return IconLoader.toImage(filtered, scale) + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/ModuleImageResourceLoader.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/ModuleImageResourceLoader.kt new file mode 100644 index 0000000000000..e9305e485168b --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/ModuleImageResourceLoader.kt @@ -0,0 +1,35 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering.images + +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.ide.plugins.contentModules +import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.util.IntellijInternalApi +import com.intellij.ui.icons.findIconLoaderByPath +import org.jetbrains.icons.impl.intellij.ModuleImageResourceLocation +import org.jetbrains.icons.rendering.ImageModifiers +import org.jetbrains.icons.rendering.ImageResource +import org.jetbrains.icons.rendering.ImageResourceLoader + +class ModuleImageResourceLoader: ImageResourceLoader { + override fun loadImage( + location: ModuleImageResourceLocation, + imageModifiers: ImageModifiers?, + ): ImageResource { + val classLoader = getClassLoader(location.pluginId, location.moduleId) + ?: error("Cannot recover classloader for plugin: ${location.pluginId} module: ${location.moduleId}") + val dataLoader = findIconLoaderByPath(location.path, classLoader) + return IntelliJImageResource(DataLoaderImageResourceHolder(dataLoader), imageModifiers) + } + + @OptIn(IntellijInternalApi::class) + private fun getClassLoader(pluginId: String, moduleId: String?): ClassLoader? { + val plugin = PluginManagerCore.findPlugin(PluginId.Companion.getId(pluginId)) ?: return null + if (moduleId == null) { + return plugin.classLoader + } + else { + return plugin.contentModules.firstOrNull { it.moduleId.name == moduleId }?.classLoader + } + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/SwingImageResourceHolder.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/SwingImageResourceHolder.kt new file mode 100644 index 0000000000000..47b7e4a7f7578 --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/images/SwingImageResourceHolder.kt @@ -0,0 +1,41 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering.images + +import com.intellij.ui.icons.LoadIconParameters +import com.intellij.ui.scale.ScaleContext +import org.jetbrains.icons.impl.intellij.rendering.toAwtFilter +import org.jetbrains.icons.impl.intellij.rendering.toIJPatcher +import org.jetbrains.icons.impl.rendering.DefaultImageModifiers +import org.jetbrains.icons.modifiers.svgPatcher +import org.jetbrains.icons.patchers.combineWith +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.ImageModifiers +import java.awt.Image +import java.awt.image.ImageFilter + +internal interface SwingImageResourceHolder { + fun getImage(scale: ScaleContext, imageModifiers: ImageModifiers?): Image? + fun getExpectedDimensions(): Dimensions +} + +internal fun ImageModifiers?.toLoadParameters(): LoadIconParameters { + val filters = mutableListOf() + val colorFilter = this?.colorFilter + if (colorFilter != null) { + filters.add(colorFilter.toAwtFilter()) + } + val knownModifiers = this as? DefaultImageModifiers + val strokePatcher = knownModifiers?.stroke?.let { stroke -> + svgPatcher { + replace("fill", "transparent") + add("stroke", stroke.toHex()) + } + } + val colorPatcher = (this?.svgPatcher combineWith strokePatcher)?.toIJPatcher() + return LoadIconParameters( + filters = filters, + isDark = knownModifiers?.isDark ?: false, + colorPatcher = colorPatcher, + isStroke = knownModifiers?.stroke != null + ) +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/toAwtColor.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/toAwtColor.kt new file mode 100644 index 0000000000000..8ed0475bb784c --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/rendering/toAwtColor.kt @@ -0,0 +1,12 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.rendering + +import org.jetbrains.icons.design.Color +import org.jetbrains.icons.design.RGBA + +fun Color.toAwtColor(): java.awt.Color { + @Suppress("UseJBColor") + when (this) { + is RGBA -> return java.awt.Color(red, green, blue, alpha) + } +} \ No newline at end of file diff --git a/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/serializers/CachedImageIconSerializer.kt b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/serializers/CachedImageIconSerializer.kt new file mode 100644 index 0000000000000..8481bd669758b --- /dev/null +++ b/platform/icons-impl/intellij/src/org/jetbrains/icons/impl/intellij/serializers/CachedImageIconSerializer.kt @@ -0,0 +1,35 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij.serializers + +import com.intellij.ui.icons.CachedImageIcon +import com.intellij.ui.icons.decodeCachedImageIconFromByteArray +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.jetbrains.icons.impl.intellij.custom.CustomLegacyIconSerializer + +class CustomCachedImageIconSerializer: CustomLegacyIconSerializer(CachedImageIcon::class) { + override fun createSerializer(): KSerializer = CachedImageIconSerializer +} + +object CachedImageIconSerializer: KSerializer { + private val actualSerializer = SerializedIconDataHolder.serializer() + + override val descriptor: SerialDescriptor = actualSerializer.descriptor + + override fun serialize(encoder: Encoder, value: CachedImageIcon) { + actualSerializer.serialize(encoder, SerializedIconDataHolder(value.encodeToByteArray())) + } + + override fun deserialize(decoder: Decoder): CachedImageIcon { + val byteArray = actualSerializer.deserialize(decoder).data + return decodeCachedImageIconFromByteArray(byteArray) as? CachedImageIcon ?: error("Unable to restore CachedImageIcon from byte array") + } +} + +@Serializable +class SerializedIconDataHolder( + val data: ByteArray +) \ No newline at end of file diff --git a/platform/icons-impl/intellij/tests/BUILD.bazel b/platform/icons-impl/intellij/tests/BUILD.bazel new file mode 100644 index 0000000000000..5b098bec845f6 --- /dev/null +++ b/platform/icons-impl/intellij/tests/BUILD.bazel @@ -0,0 +1,75 @@ +### auto-generated section `build intellij.platform.icons.impl.intellij.tests` start +load("@rules_jvm//:jvm.bzl", "jvm_library") + +jvm_library( + name = "tests", + visibility = ["//visibility:public"], + srcs = glob([], allow_empty = True), + exports = [ + "//platform/icons-api", + "//platform/icons-api/rendering", + "//platform/icons-impl", + "//platform/icons-impl/intellij", + ], + runtime_deps = [ + "@lib//:kotlin-stdlib", + "//libraries/kotlinx/coroutines/core", + "//platform/icons-api", + "//platform/icons-api/rendering", + "//platform/icons-impl", + "//platform/icons-impl/intellij", + ] +) + +jvm_library( + name = "tests_test_lib", + module_name = "intellij.platform.icons.impl.intellij.tests", + visibility = ["//visibility:public"], + srcs = glob(["test/**/*.kt", "test/**/*.java", "test/**/*.form"], allow_empty = True), + deps = [ + "@lib//:kotlin-stdlib", + "//libraries/kotlinx/coroutines/core", + "//libraries/kotlinx/coroutines/core:core_test_lib", + "//platform/icons-api", + "//platform/icons-api:icons-api_test_lib", + "//platform/icons-api/rendering", + "//platform/icons-api/rendering:rendering_test_lib", + "//platform/icons-impl", + "//platform/icons-impl:icons-impl_test_lib", + "//platform/icons-impl/intellij", + "//platform/icons-impl/intellij:intellij_test_lib", + "//libraries/junit5", + "//libraries/junit5:junit5_test_lib", + "//libraries/kotlinx/serialization/json", + "//libraries/kotlinx/serialization/json:json_test_lib", + "@lib//:assert_j", + "@lib//:junit5", + "//platform/testFramework/junit5", + "//platform/testFramework/junit5:junit5_test_lib", + "//platform/icons", + "//platform/icons:icons_test_lib", + "//platform/testFramework", + "//platform/testFramework:testFramework_test_lib", + ], + exports = [ + "//platform/icons-api", + "//platform/icons-api:icons-api_test_lib", + "//platform/icons-api/rendering", + "//platform/icons-api/rendering:rendering_test_lib", + "//platform/icons-impl", + "//platform/icons-impl:icons-impl_test_lib", + "//platform/icons-impl/intellij", + "//platform/icons-impl/intellij:intellij_test_lib", + ], + runtime_deps = [":tests"] +) +### auto-generated section `build intellij.platform.icons.impl.intellij.tests` end + +### auto-generated section `test intellij.platform.icons.impl.intellij.tests` start +load("@community//build:tests-options.bzl", "jps_test") + +jps_test( + name = "tests_test", + runtime_deps = [":tests_test_lib"] +) +### auto-generated section `test intellij.platform.icons.impl.intellij.tests` end \ No newline at end of file diff --git a/platform/icons-impl/intellij/tests/intellij.platform.icons.impl.intellij.tests.iml b/platform/icons-impl/intellij/tests/intellij.platform.icons.impl.intellij.tests.iml new file mode 100644 index 0000000000000..794e2ad44fe6f --- /dev/null +++ b/platform/icons-impl/intellij/tests/intellij.platform.icons.impl.intellij.tests.iml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + $USER_HOME$/Library/Caches/JetBrains/IntelliJIdea2025.3/kotlin-dist-for-ide/2.3.20-RC2/lib/kotlinx-serialization-compiler-plugin.jar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/platform/icons-impl/intellij/tests/test/org/jetbrains/icons/impl/intellij/IconTest.kt b/platform/icons-impl/intellij/tests/test/org/jetbrains/icons/impl/intellij/IconTest.kt new file mode 100644 index 0000000000000..9c16d71f984d4 --- /dev/null +++ b/platform/icons-impl/intellij/tests/test/org/jetbrains/icons/impl/intellij/IconTest.kt @@ -0,0 +1,26 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.intellij + +import com.intellij.testFramework.junit5.TestApplication +import org.jetbrains.icons.imageIcon +import org.jetbrains.icons.swing.toSwingIcon +import org.junit.jupiter.api.Test +import java.awt.image.BufferedImage + +@TestApplication +class IconTest { + @Test + fun `should render icon to buffered image`() { + IntelliJIconManager.activate() + val icon = imageIcon("actions/addFile.svg", IconTest::class.java.classLoader) + + val swingIcon = icon.toSwingIcon() + val image = BufferedImage(swingIcon.iconWidth, swingIcon.iconHeight, BufferedImage.TYPE_INT_ARGB) + val g2 = image.createGraphics() + try { + swingIcon.paintIcon(null, g2, 0, 0) + } finally { + g2.dispose() + } + } +} diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/DefaultDeferredIcon.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/DefaultDeferredIcon.kt new file mode 100644 index 0000000000000..9b4bd57c77873 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/DefaultDeferredIcon.kt @@ -0,0 +1,75 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.DeferredIcon +import org.jetbrains.icons.Icon +import org.jetbrains.icons.IconIdentifier +import java.lang.ref.WeakReference + +@Serializable +open class DefaultDeferredIcon( + override val id: IconIdentifier, + /** + * Placeholder that is renderer when the icon is not resolved yet. + * Keep in mind that implementation might change this to null when + * the icon is resolved to reduce memory footprint when serialized. + */ + override var placeholder: Icon? +): DeferredIcon { + @Transient + private val listeners = mutableListOf>() + + @ApiStatus.Internal + fun addDoneListener(listener: DeferredIconEventHandler) { + listeners.add(WeakReference(listener)) + } + + @ApiStatus.Internal + fun markDone(resolvedIcon: Icon) { + this.placeholder = null + for (listener in listeners) { + listener.get()?.whenDone(this, resolvedIcon) + } + listeners.clear() + } + +} + +class DefaultDeferredIconSerializer( + private val manager: DefaultIconManager +) : KSerializer { + private val delegate = DefaultDeferredIcon.serializer() + + override val descriptor: SerialDescriptor = delegate.descriptor + + override fun serialize(encoder: Encoder, value: DefaultDeferredIcon) { + delegate.serialize(encoder, value) + } + + override fun deserialize(decoder: Decoder): DefaultDeferredIcon { + val result = delegate.deserialize(decoder) + return manager.registerDeserializedDeferredIcon(result) + } +} + +interface DeferredIconEventHandler { + fun whenDone(deferredIcon: DeferredIcon, resolvedIcon: Icon) +} + +/** + * Responsible for resolving deferred icons, + * and also synchronization between instances and backend/frontend. + */ +interface DeferredIconResolver { + val id: IconIdentifier + val deferredIcon: WeakReference + suspend fun resolve(): Icon +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/DefaultIconManager.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/DefaultIconManager.kt new file mode 100644 index 0000000000000..3c5935360b87a --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/DefaultIconManager.kt @@ -0,0 +1,143 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.serialDescriptor +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.SerializersModuleBuilder +import org.jetbrains.icons.DeferredIcon +import org.jetbrains.icons.Icon +import org.jetbrains.icons.IconIdentifier +import org.jetbrains.icons.IconManager +import org.jetbrains.icons.impl.layers.AnimatedIconLayer +import org.jetbrains.icons.impl.layers.IconIconLayer +import org.jetbrains.icons.impl.layers.ImageIconLayer +import org.jetbrains.icons.impl.layers.LayoutIconLayer +import org.jetbrains.icons.impl.layers.ShapeIconLayer +import org.jetbrains.icons.modifiers.IconModifier +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicInteger + +abstract class DefaultIconManager: IconManager { + protected abstract val resolverService: DeferredIconResolverService + + private val deferredIconDeserializer by lazy { + DefaultDeferredIconSerializer(this) + } + + override fun deferredIcon(placeholder: Icon?, identifier: String?, classLoader: ClassLoader?, evaluator: suspend () -> Icon): Icon { + return resolverService.getOrCreateDeferredIcon( + generateDeferredIconIdentifier(identifier, classLoader), + placeholder + ) { id, ref -> + createDeferredIconResolver(id, ref, evaluator) + } + } + + internal fun registerDeserializedDeferredIcon(icon: DefaultDeferredIcon): DefaultDeferredIcon { + return resolverService.register(icon) { id, ref -> + createDeferredIconResolver(id, ref, null) + } + } + + protected open fun generateDeferredIconIdentifier(id: String?, classLoader: ClassLoader? = null): IconIdentifier { + if (id != null) return StringIconIdentifier(id) + return StringIconIdentifier("dynamicIcon_" + dynamicIconNextId.getAndIncrement().toString()) + } + + override suspend fun forceEvaluation(icon: DeferredIcon): Icon { + return resolverService.forceEvaluation(icon) + } + + fun scheduleEvaluation(icon: DeferredIcon) { + resolverService.scheduleEvaluation(icon) + } + + protected open fun createDeferredIconResolver( + id: IconIdentifier, + ref: WeakReference, + evaluator: (suspend () -> Icon)?, + ): DeferredIconResolver { + if (evaluator == null) error("Evaluator is not specified for icon $id") + return InPlaceDeferredIconResolver(resolverService, id, ref, evaluator) + } + + open fun SerializersModuleBuilder.buildCustomSerializers() { + // Add nothing by default + } + abstract suspend fun sendDeferredNotifications(id: IconIdentifier, result: Icon) + abstract fun markDeferredIconUnused(id: IconIdentifier) + + override fun getSerializersModule(): SerializersModule { + return SerializersModule { + polymorphic(Icon::class, DefaultLayeredIcon::class, DefaultLayeredIcon.serializer()) + polymorphic(Icon::class, DefaultDeferredIcon::class, deferredIconDeserializer) + polymorphic(DeferredIcon::class, DefaultDeferredIcon::class, deferredIconDeserializer) + polymorphic( + IconModifier::class, + IconModifier.Companion::class, + IconModifierConstSerializer + ) + polymorphic( + IconIdentifier::class, + StringIconIdentifier::class, + StringIconIdentifier.serializer() + ) + + iconLayer(AnimatedIconLayer::class) + iconLayer(IconIconLayer::class) + iconLayer(ImageIconLayer::class) + iconLayer(LayoutIconLayer::class) + iconLayer(ShapeIconLayer::class) + + buildCustomSerializers() + } + } + + override fun toSwingIcon(icon: Icon): javax.swing.Icon { + error("Swing Icons are not supported.") + } + + companion object { + fun getDefaultManagerInstance(): DefaultIconManager { + return IconManager.getInstance() as? DefaultIconManager ?: error("IconManager is not DefaultIconManager.") + } + + private val dynamicIconNextId = AtomicInteger() + } +} + +private object IconModifierConstSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("IconModifier.Companion") { + element("isEmpty", serialDescriptor()) + } + + override fun serialize(encoder: Encoder, value: IconModifier.Companion) { + encoder.encodeStructure(descriptor) { + encodeBooleanElement(descriptor, 0, true) + } + } + + override fun deserialize(decoder: Decoder): IconModifier.Companion { + var v: Boolean? = null + decoder.decodeStructure(descriptor) { + while (true) { + when (val index = decodeElementIndex(descriptor)) { + CompositeDecoder.DECODE_DONE -> break + 0 -> v = decodeBooleanElement(descriptor, 0) + else -> error("Unexpected element index: $index") + } + } + } + require(v == true) { "Unexpected value: '$v'" } + return IconModifier + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/DefaultLayeredIcon.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/DefaultLayeredIcon.kt new file mode 100644 index 0000000000000..8e747ba3baf37 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/DefaultLayeredIcon.kt @@ -0,0 +1,29 @@ +// 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.icons.impl + +import kotlinx.serialization.Serializable +import org.jetbrains.icons.Icon +import org.jetbrains.icons.layers.IconLayer + +@Serializable +class DefaultLayeredIcon( + val layers: List +): Icon { + override fun toString(): String { + return "DefaultIcon(layers=$layers)" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DefaultLayeredIcon + + return layers == other.layers + } + + override fun hashCode(): Int { + return layers.hashCode() + } +} + diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/DeferredIconResolverService.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/DeferredIconResolverService.kt new file mode 100644 index 0000000000000..962c2f18dcb64 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/DeferredIconResolverService.kt @@ -0,0 +1,82 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.DeferredIcon +import org.jetbrains.icons.Icon +import org.jetbrains.icons.IconIdentifier +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.seconds + +@ApiStatus.Internal +open class DeferredIconResolverService( + protected val scope: CoroutineScope +) { + protected val iconReferenceQueue = ReferenceQueue() + protected val resolvers = ConcurrentHashMap() + + init { + scope.launch { + while (true) { + delay(5.seconds) + cleanUnusedIcons() + } + } + } + + open fun getOrCreateDeferredIcon( + identifier: IconIdentifier, + placeholder: Icon?, + resolverBuilder: (IconIdentifier, WeakReference) -> DeferredIconResolver + ): Icon { + val resolver = resolvers.getOrPut(identifier) { + val icon = DefaultDeferredIcon(identifier, placeholder) + resolverBuilder(icon.id, IdentifiedDeferredIconWeakReference(icon, iconReferenceQueue)) + } + return resolver?.deferredIcon?.get() ?: DefaultDeferredIcon(identifier, placeholder) + } + + open fun register( + icon: DefaultDeferredIcon, + resolverBuilder: (IconIdentifier, WeakReference) -> DeferredIconResolver + ): DefaultDeferredIcon { + return resolvers.getOrPut(icon.id) { + resolverBuilder(icon.id, IdentifiedDeferredIconWeakReference(icon, iconReferenceQueue)) + }?.deferredIcon?.get() ?: icon + } + + open fun scheduleEvaluation(icon: DeferredIcon) { + scope.launch { + forceEvaluation(icon) + } + } + + open suspend fun forceEvaluation(icon: DeferredIcon): Icon { + val resolver = resolvers[icon.id] ?: error("Cannot find resolver for icon: $icon") + return resolver.resolve() + } + + open fun cleanIcon(id: IconIdentifier) { + resolvers.remove(id) + } + + protected open fun cleanUnusedIcons() { + while (true) { + val reference = iconReferenceQueue.poll() ?: break + val id = (reference as IdentifiedDeferredIconWeakReference).id ?: continue + DefaultIconManager.getDefaultManagerInstance().markDeferredIconUnused(id) + } + } + + protected open class IdentifiedDeferredIconWeakReference( + instance: DefaultDeferredIcon, + queue: ReferenceQueue, + ): WeakReference(instance, queue) { + val id = instance.id + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/IconAnimationFrame.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/IconAnimationFrame.kt new file mode 100644 index 0000000000000..d7bc85c44cabb --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/IconAnimationFrame.kt @@ -0,0 +1,33 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl + +import kotlinx.serialization.Serializable +import org.jetbrains.icons.layers.IconLayer + +@Serializable +class IconAnimationFrame( + val layers: List, + val duration: Long +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IconAnimationFrame + + if (duration != other.duration) return false + if (layers != other.layers) return false + + return true + } + + override fun hashCode(): Int { + var result = duration.hashCode() + result = 31 * result + layers.hashCode() + return result + } + + override fun toString(): String { + return "IconAnimationFrame(duration=$duration, layers=$layers)" + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/InPlaceDeferredIconResolver.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/InPlaceDeferredIconResolver.kt new file mode 100644 index 0000000000000..7cdcd0237c28b --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/InPlaceDeferredIconResolver.kt @@ -0,0 +1,36 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl + +import kotlinx.coroutines.CompletableDeferred +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.Icon +import org.jetbrains.icons.IconIdentifier +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicBoolean + +@ApiStatus.Internal +class InPlaceDeferredIconResolver( + val service: DeferredIconResolverService, + override val id: IconIdentifier, + override val deferredIcon: WeakReference, + val evaluator: suspend () -> Icon +): DeferredIconResolver { + var resolvedIcon: Icon? = null + private val deferredValue = CompletableDeferred() + private val isPending = AtomicBoolean(false) + + override suspend fun resolve(): Icon { + val resolved = resolvedIcon + if (resolved != null) return resolved + if (!isPending.getAndSet(true)) { + val result = evaluator() + deferredValue.complete(result) + resolvedIcon = result + deferredIcon.get()?.markDone(result) + DefaultIconManager.getDefaultManagerInstance().sendDeferredNotifications(id, result) + return result + } + return deferredValue.await() + } +} + diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/SerializersBuilder.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/SerializersBuilder.kt new file mode 100644 index 0000000000000..8905b5cecda49 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/SerializersBuilder.kt @@ -0,0 +1,14 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl + +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.modules.SerializersModuleBuilder +import kotlinx.serialization.serializer +import org.jetbrains.icons.layers.IconLayer +import kotlin.reflect.KClass + +@OptIn(InternalSerializationApi::class) +fun SerializersModuleBuilder.iconLayer(klass: KClass, serializer: KSerializer? = null) { + polymorphic(IconLayer::class, klass, serializer ?: klass.serializer()) +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/StringIconIdentifier.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/StringIconIdentifier.kt new file mode 100644 index 0000000000000..85436758af324 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/StringIconIdentifier.kt @@ -0,0 +1,27 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl + +import kotlinx.serialization.Serializable +import org.jetbrains.icons.IconIdentifier + +@Serializable +class StringIconIdentifier( + val value: String +): IconIdentifier { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StringIconIdentifier + + return value == other.value + } + + override fun hashCode(): Int { + return value.hashCode() + } + + override fun toString(): String { + return value + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/design/DefaultIconAnimationDesigner.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/design/DefaultIconAnimationDesigner.kt new file mode 100644 index 0000000000000..bd2c92a214676 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/design/DefaultIconAnimationDesigner.kt @@ -0,0 +1,22 @@ +// 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.icons.impl.design + +import org.jetbrains.icons.design.IconAnimationDesigner +import org.jetbrains.icons.design.IconDesigner +import org.jetbrains.icons.impl.IconAnimationFrame + +class DefaultIconAnimationDesigner( + val rootDesigner: DefaultIconDesigner +): IconAnimationDesigner { + private val frames = mutableListOf() + + override fun frame(duration: Long, builder: IconDesigner.() -> Unit) { + val designer = rootDesigner.createNestedDesigner() + designer.builder() + frames.add(IconAnimationFrame(designer.buildLayers(), duration)) + } + + fun build(): List { + return frames + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/design/DefaultIconDesigner.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/design/DefaultIconDesigner.kt new file mode 100644 index 0000000000000..00f5c71055abf --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/design/DefaultIconDesigner.kt @@ -0,0 +1,70 @@ +// 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.icons.impl.design + +import org.jetbrains.icons.Icon +import org.jetbrains.icons.design.Shape +import org.jetbrains.icons.design.Color +import org.jetbrains.icons.design.IconAnimationDesigner +import org.jetbrains.icons.design.IconDesigner +import org.jetbrains.icons.design.IconUnit +import org.jetbrains.icons.modifiers.IconModifier +import org.jetbrains.icons.impl.DefaultLayeredIcon +import org.jetbrains.icons.impl.layers.AnimatedIconLayer +import org.jetbrains.icons.impl.layers.IconIconLayer +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.impl.layers.ImageIconLayer +import org.jetbrains.icons.impl.layers.LayoutIconLayer +import org.jetbrains.icons.ImageResourceLocation +import org.jetbrains.icons.impl.layers.ShapeIconLayer + +abstract class DefaultIconDesigner: IconDesigner { + private val layers = mutableListOf() + + override fun image(resourceLoader: ImageResourceLocation, modifier: IconModifier) { + layers.add(ImageIconLayer(resourceLoader, modifier)) + } + + override fun icon(icon: Icon, modifier: IconModifier) { + layers.add(IconIconLayer(icon, modifier)) + } + + override fun custom(iconLayer: IconLayer) { + layers.add(iconLayer) + } + + override fun box(modifier: IconModifier, builder: IconDesigner.() -> Unit) { + layout(LayoutIconLayer.LayoutDirection.Box, IconUnit.Zero, modifier, builder) + } + + override fun row(spacing: IconUnit, modifier: IconModifier, builder: IconDesigner.() -> Unit) { + layout(LayoutIconLayer.LayoutDirection.Row, spacing, modifier, builder) + } + + override fun column(spacing: IconUnit, modifier: IconModifier, builder: IconDesigner.() -> Unit) { + layout(LayoutIconLayer.LayoutDirection.Column, spacing, modifier, builder) + } + + private fun layout(direction: LayoutIconLayer.LayoutDirection, spacing: IconUnit, modifier: IconModifier, builder: IconDesigner.() -> Unit) { + val nestedIconDesigner = createNestedDesigner() + nestedIconDesigner.builder() + layers.add(LayoutIconLayer(nestedIconDesigner.buildLayers(), direction, spacing, modifier)) + } + + override fun animation(modifier: IconModifier, builder: IconAnimationDesigner.() -> Unit) { + val designer = DefaultIconAnimationDesigner(this) + designer.builder() + layers.add(AnimatedIconLayer(designer.build(), modifier)) + } + + override fun shape(color: Color, shape: Shape, modifier: IconModifier) { + layers.add(ShapeIconLayer(color, shape, modifier)) + } + + abstract fun createNestedDesigner(): DefaultIconDesigner + + fun build(): DefaultLayeredIcon { + return DefaultLayeredIcon(buildLayers()) + } + + fun buildLayers(): List = layers.toList() +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/layers/AnimatedIconLayer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/AnimatedIconLayer.kt new file mode 100644 index 0000000000000..f8af43a9d736e --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/AnimatedIconLayer.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.icons.impl.layers + +import kotlinx.serialization.Serializable +import org.jetbrains.icons.modifiers.IconModifier +import org.jetbrains.icons.impl.IconAnimationFrame +import org.jetbrains.icons.layers.IconLayer + +@Serializable +class AnimatedIconLayer( + val frames: List, + override val modifier: IconModifier +): IconLayer { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AnimatedIconLayer + + if (frames != other.frames) return false + if (modifier != other.modifier) return false + + return true + } + + override fun hashCode(): Int { + var result = frames.hashCode() + result = 31 * result + modifier.hashCode() + return result + } + + override fun toString(): String { + return "AnimatedIconLayer(frames=$frames, modifier=$modifier)" + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/layers/IconIconLayer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/IconIconLayer.kt new file mode 100644 index 0000000000000..cf4245faf8c6e --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/IconIconLayer.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.icons.impl.layers + +import kotlinx.serialization.Serializable +import org.jetbrains.icons.Icon +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.modifiers.IconModifier + +@Serializable +class IconIconLayer( + val icon: Icon, + override val modifier: IconModifier +): IconLayer { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IconIconLayer + + if (icon != other.icon) return false + if (modifier != other.modifier) return false + + return true + } + + override fun hashCode(): Int { + var result = icon.hashCode() + result = 31 * result + modifier.hashCode() + return result + } + + override fun toString(): String { + return "IconIconLayer(icon=$icon, modifier=$modifier)" + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/layers/IconLayerConstraints.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/IconLayerConstraints.kt new file mode 100644 index 0000000000000..9d9be222cc778 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/IconLayerConstraints.kt @@ -0,0 +1,44 @@ +// 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.icons.impl.layers + +import kotlinx.serialization.Serializable +import org.jetbrains.icons.design.IconAlign +import org.jetbrains.icons.design.IconMargin +import org.jetbrains.icons.design.IconUnit + +@Serializable +class IconLayerConstraints( + val align: IconAlign, + val width: IconUnit, + val height: IconUnit, + val margin: IconMargin, + val alpha: Float = 1.0f +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as IconLayerConstraints + + if (align != other.align) return false + if (width != other.width) return false + if (height != other.height) return false + if (margin != other.margin) return false + if (alpha != other.alpha) return false + + return true + } + + override fun hashCode(): Int { + var result = align.hashCode() + result = 31 * result + width.hashCode() + result = 31 * result + height.hashCode() + result = 31 * result + margin.hashCode() + result = 31 * result + alpha.hashCode() + return result + } + + override fun toString(): String { + return "IconLayerConstraints(align=$align, width=$width, height=$height, margin=$margin, opacity=$alpha)" + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/layers/ImageIconLayer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/ImageIconLayer.kt new file mode 100644 index 0000000000000..40ec711a75ecb --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/ImageIconLayer.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.icons.impl.layers + +import kotlinx.serialization.Serializable +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.modifiers.IconModifier +import org.jetbrains.icons.ImageResourceLocation + +@Serializable +class ImageIconLayer( + val loader: ImageResourceLocation, + override val modifier: IconModifier +) : IconLayer { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ImageIconLayer + + if (loader != other.loader) return false + if (modifier != other.modifier) return false + + return true + } + + override fun hashCode(): Int { + var result = loader.hashCode() + result = 31 * result + modifier.hashCode() + return result + } + + override fun toString(): String { + return "ImageIconLayer(loader=$loader, modifier=$modifier)" + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/layers/LayoutIconLayer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/LayoutIconLayer.kt new file mode 100644 index 0000000000000..9a6b3da829c72 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/LayoutIconLayer.kt @@ -0,0 +1,45 @@ +// 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.icons.impl.layers + +import kotlinx.serialization.Serializable +import org.jetbrains.icons.design.IconUnit +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.modifiers.IconModifier + +@Serializable +class LayoutIconLayer( + val nestedLayers: List, + val direction: LayoutDirection, + val spacing: IconUnit, + override val modifier: IconModifier +) : IconLayer { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LayoutIconLayer + + if (nestedLayers != other.nestedLayers) return false + if (direction != other.direction) return false + if (modifier != other.modifier) return false + + return true + } + + override fun hashCode(): Int { + var result = nestedLayers.hashCode() + result = 31 * result + direction.hashCode() + result = 31 * result + modifier.hashCode() + return result + } + + override fun toString(): String { + return "ColumnIconLayer(nestedLayers=$nestedLayers, direction=$direction, modifier=$modifier)" + } + + enum class LayoutDirection { + Row, + Column, + Box + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/layers/ModifierHelpers.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/ModifierHelpers.kt new file mode 100644 index 0000000000000..d6ecdbdf70192 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/ModifierHelpers.kt @@ -0,0 +1,32 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.layers + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.modifiers.CombinedIconModifier +import org.jetbrains.icons.modifiers.IconModifier + +@ApiStatus.Internal +inline fun IconLayer.findModifier(): TModifier? { + var output: TModifier? = null + traverseModifiers(modifier) { + if (it is TModifier) { + output = it + return@traverseModifiers false + } + return@traverseModifiers true + } + return output +} + +@ApiStatus.Internal +fun traverseModifiers(modifier: IconModifier, traverser: (IconModifier) -> Boolean): Boolean { + if (traverser(modifier)) { + if (modifier is CombinedIconModifier) { + if (traverseModifiers(modifier.other, traverser)) { + return traverseModifiers(modifier.root, traverser) + } else return false + } else return true + } + return false +} diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/layers/ShapeIconLayer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/ShapeIconLayer.kt new file mode 100644 index 0000000000000..dd13333c49d6d --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/layers/ShapeIconLayer.kt @@ -0,0 +1,40 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.layers + +import kotlinx.serialization.Serializable +import org.jetbrains.icons.design.Shape +import org.jetbrains.icons.design.Color +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.modifiers.IconModifier + +@Serializable +class ShapeIconLayer( + val color: Color, + val shape: Shape, + override val modifier: IconModifier +): IconLayer { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ShapeIconLayer + + if (color != other.color) return false + if (shape != other.shape) return false + if (modifier != other.modifier) return false + + return true + } + + override fun hashCode(): Int { + var result = color.hashCode() + result = 31 * result + shape.hashCode() + result = 31 * result + modifier.hashCode() + return result + } + + override fun toString(): String { + return "BadgeIconLayer(color=$color, badgeShape=$shape, modifier=$modifier)" + } + +} diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/CachedGPUImageResourceHolder.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/CachedGPUImageResourceHolder.kt new file mode 100644 index 0000000000000..599b4c09c67bd --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/CachedGPUImageResourceHolder.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.icons.impl.rendering + +import org.jetbrains.icons.rendering.lowlevel.GPUImageResourceHolder +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentMap +import kotlin.reflect.KClass + +open class CachedGPUImageResourceHolder: GPUImageResourceHolder { + private val cache: ConcurrentMap, Any> = ConcurrentHashMap() + + override fun getOrGenerateBitmap(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/icons-impl/src/org/jetbrains/icons/impl/rendering/CoroutineBasedMutableIconUpdateFlow.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/CoroutineBasedMutableIconUpdateFlow.kt new file mode 100644 index 0000000000000..f0102b588321a --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/CoroutineBasedMutableIconUpdateFlow.kt @@ -0,0 +1,29 @@ +// 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.icons.impl.rendering + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import org.jetbrains.icons.Icon + +class CoroutineBasedMutableIconUpdateFlow( + private val coroutineScope: CoroutineScope, + updateCallback: (Int) -> Unit +): MutableIconUpdateFlowBase(updateCallback) { + override fun MutableSharedFlow.emitDelayed(delay: Long, value: Int) { + coroutineScope.launch { + delay(delay) + if (handleRateLimiting()) return@launch + emit(value) + updateCallback(value) + } + } + + override fun collectDynamic(flow: Flow, handler: (Icon) -> Unit) { + coroutineScope.launch { + flow.collect { handler(it) } + } + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultDeferredIconRenderer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultDeferredIconRenderer.kt new file mode 100644 index 0000000000000..19aec75288f69 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultDeferredIconRenderer.kt @@ -0,0 +1,49 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.DeferredIcon +import org.jetbrains.icons.Icon +import org.jetbrains.icons.IconManager +import org.jetbrains.icons.impl.DefaultDeferredIcon +import org.jetbrains.icons.impl.DefaultIconManager +import org.jetbrains.icons.impl.DeferredIconEventHandler +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.IconRenderer +import org.jetbrains.icons.rendering.LoadingStrategy +import org.jetbrains.icons.rendering.PaintingApi +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.rendering.createRenderer + +internal class DefaultDeferredIconRenderer( + override val icon: DefaultDeferredIcon, + val renderingContext: RenderingContext, + val loadingStrategy: LoadingStrategy +): IconRenderer, DeferredIconEventHandler { + private var isDone = false + private var renderer = icon.placeholder?.createRenderer(renderingContext, loadingStrategy) + + override fun whenDone(deferredIcon: DeferredIcon, resolvedIcon: Icon) { + val oldRenderer = renderer + val strategy = if (oldRenderer != null) { + LoadingStrategy.RenderPlaceholder(oldRenderer) + } else loadingStrategy + renderer = resolvedIcon.createRenderer(renderingContext, strategy) + isDone = true + renderingContext.updateFlow.triggerUpdate() + } + + @ApiStatus.Internal + override fun render(api: PaintingApi) { + if (!isDone) { + DefaultIconManager.getDefaultManagerInstance().scheduleEvaluation(icon) + } + renderer?.render(api) + } + + @ApiStatus.Internal + override fun calculateExpectedDimensions(scaling: ScalingContext): Dimensions { + return renderer?.calculateExpectedDimensions(scaling) ?: Dimensions(0, 0) + } +} diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultIconRenderer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultIconRenderer.kt new file mode 100644 index 0000000000000..7f67a23de54ea --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultIconRenderer.kt @@ -0,0 +1,52 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering + +import org.jetbrains.icons.Icon +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.IconRenderer +import org.jetbrains.icons.rendering.LoadingStrategy +import org.jetbrains.icons.rendering.PaintingApi +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.impl.DefaultLayeredIcon +import org.jetbrains.icons.impl.rendering.layers.IconLayerRenderer +import org.jetbrains.icons.impl.rendering.layers.IconLayerManager +import kotlin.compareTo + +class DefaultIconRenderer( + val iconInstance: DefaultLayeredIcon, + private val context: RenderingContext, + private val loadingStrategy: LoadingStrategy +) : IconRenderer { + override val icon: Icon = iconInstance + private var isLoaded = false + private val layerRenderers = createRenderers() + + private fun createRenderers(): List { + val manager = IconLayerManager.getInstance() + val renderers = iconInstance.layers.map { manager.createRenderer(it, context) } + isLoaded = true + return renderers + } + + override fun render(api: PaintingApi) { + for (layer in layerRenderers) { + layer.render(api) + } + } + + override fun calculateExpectedDimensions(scaling: ScalingContext): Dimensions { + var width = 0 + var height = 0 + for (layer in layerRenderers) { + val dimensions = layer.calculateExpectedDimensions(scaling) + if (dimensions.width > width) { + width = dimensions.width + } + if (dimensions.height > height) { + height = dimensions.height + } + } + return Dimensions(width, height) + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultIconRendererManager.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultIconRendererManager.kt new file mode 100644 index 0000000000000..cec0da3d961cb --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultIconRendererManager.kt @@ -0,0 +1,72 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering + +import org.jetbrains.icons.Icon +import org.jetbrains.icons.rendering.IconRenderer +import org.jetbrains.icons.rendering.IconRendererManager +import org.jetbrains.icons.rendering.LoadingStrategy +import org.jetbrains.icons.impl.layers.AnimatedIconLayer +import org.jetbrains.icons.impl.DefaultLayeredIcon +import org.jetbrains.icons.impl.layers.IconIconLayer +import org.jetbrains.icons.impl.layers.ImageIconLayer +import org.jetbrains.icons.impl.layers.LayoutIconLayer +import org.jetbrains.icons.impl.rendering.layers.AnimatedIconLayerRenderer +import org.jetbrains.icons.impl.rendering.layers.IconIconLayerRenderer +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.impl.rendering.layers.IconLayerRenderer +import org.jetbrains.icons.impl.rendering.layers.IconLayerManager +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.impl.DefaultDeferredIcon +import org.jetbrains.icons.impl.layers.ShapeIconLayer +import org.jetbrains.icons.impl.rendering.layers.ShapeIconLayerRenderer +import org.jetbrains.icons.impl.rendering.layers.ImageIconLayerRenderer +import org.jetbrains.icons.impl.rendering.layers.LayoutIconLayerRenderer + +abstract class DefaultIconRendererManager: IconRendererManager, IconLayerManager { + init { + IconLayerManager.setInstance(this) + } + + override fun createRenderer(icon: Icon, context: RenderingContext, loadingStrategy: LoadingStrategy): IconRenderer { + return createRendererOrNull(icon, context, loadingStrategy) ?: error("Unsupported icon type: $icon") + } + + protected fun createRendererOrNull(icon: Icon, context: RenderingContext, loadingStrategy: LoadingStrategy): IconRenderer? { + return when (icon) { + is DefaultLayeredIcon -> DefaultIconRenderer(icon, context, loadingStrategy) + is DefaultDeferredIcon -> createDeferredIconRenderer(icon, context, loadingStrategy) + else -> null + } + } + + private fun createDeferredIconRenderer(icon: DefaultDeferredIcon, context: RenderingContext, loadingStrategy: LoadingStrategy): IconRenderer { + val renderer = DefaultDeferredIconRenderer(icon, context, loadingStrategy) + icon.addDoneListener(renderer) + return renderer + } + + override fun createRenderer(layer: IconLayer, renderingContext: RenderingContext): IconLayerRenderer { + return createRendererOrNull(layer, renderingContext) ?: error("Unsupported icon layer type: $layer") + } + + protected fun createRendererOrNull(layer: IconLayer, renderingContext: RenderingContext): IconLayerRenderer? { + return when (layer) { + is ImageIconLayer -> { + ImageIconLayerRenderer(layer, renderingContext) + } + is IconIconLayer -> { + IconIconLayerRenderer(layer, renderingContext) + } + is LayoutIconLayer -> { + LayoutIconLayerRenderer(layer, renderingContext) + } + is AnimatedIconLayer -> { + AnimatedIconLayerRenderer(layer, renderingContext) + } + is ShapeIconLayer -> { + ShapeIconLayerRenderer(layer, renderingContext) + } + else -> null + } + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultImageModifiers.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultImageModifiers.kt new file mode 100644 index 0000000000000..7bfa66f9f83bd --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultImageModifiers.kt @@ -0,0 +1,42 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering + +import org.jetbrains.icons.design.Color +import org.jetbrains.icons.filters.ColorFilter +import org.jetbrains.icons.patchers.SvgPatcher +import org.jetbrains.icons.rendering.ImageModifiers + +class DefaultImageModifiers( + override val colorFilter: ColorFilter? = null, + override val svgPatcher: SvgPatcher? = null, + val isDark: Boolean = false, + val stroke: Color? = null +): ImageModifiers { + override fun toString(): String { + return "DefaultImageModifiers(colorFilter=$colorFilter, svgPatcher=$svgPatcher, isDark=$isDark, stroke=$stroke)" + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DefaultImageModifiers + + if (isDark != other.isDark) return false + if (colorFilter != other.colorFilter) return false + if (svgPatcher != other.svgPatcher) return false + if (stroke != other.stroke) return false + + return true + } + + override fun hashCode(): Int { + var result = isDark.hashCode() + result = 31 * result + (colorFilter?.hashCode() ?: 0) + result = 31 * result + (svgPatcher?.hashCode() ?: 0) + result = 31 * result + (stroke?.hashCode() ?: 0) + return result + } + + +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultImageResourceProvider.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultImageResourceProvider.kt new file mode 100644 index 0000000000000..e87e0ee7caeab --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/DefaultImageResourceProvider.kt @@ -0,0 +1,11 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering + +import org.jetbrains.icons.rendering.ImageModifiers +import org.jetbrains.icons.rendering.ImageResource +import org.jetbrains.icons.rendering.ImageResourceProvider +import javax.swing.Icon + +abstract class DefaultImageResourceProvider: ImageResourceProvider { + abstract fun fromSwingIcon(icon: Icon, imageModifiers: ImageModifiers? = null): ImageResource +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/MutableIconUpdateFlowBase.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/MutableIconUpdateFlowBase.kt new file mode 100644 index 0000000000000..f8ac597c2fddd --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/MutableIconUpdateFlowBase.kt @@ -0,0 +1,39 @@ +// 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.icons.impl.rendering + +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow +import org.jetbrains.icons.rendering.MutableIconUpdateFlow +import java.util.concurrent.atomic.AtomicInteger + +abstract class MutableIconUpdateFlowBase( + protected val updateCallback: (Int) -> Unit +): MutableIconUpdateFlow { + private val updateCounter = AtomicInteger() + private val underlayingFlow = MutableSharedFlow() + protected var stopwatch = System.currentTimeMillis() + protected val minimalUpdateMillis = 1000L / 60L // Use actual fps? + + protected fun handleRateLimiting(): Boolean { + if (System.currentTimeMillis() - stopwatch < minimalUpdateMillis) return true + stopwatch = System.currentTimeMillis() + return false + } + + override fun triggerUpdate() { + if (handleRateLimiting()) return + val updateId = updateCounter.incrementAndGet() + underlayingFlow.tryEmit(updateId) + updateCallback(updateId) + } + + override fun triggerDelayedUpdate(delay: Long) { + underlayingFlow.emitDelayed(delay, updateCounter.incrementAndGet()) + } + + protected abstract fun MutableSharedFlow.emitDelayed(delay: Long, value: Int) + + override suspend fun collect(collector: FlowCollector) { + underlayingFlow.collect(collector) + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/AnimatedIconLayerRenderer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/AnimatedIconLayerRenderer.kt new file mode 100644 index 0000000000000..baf7e7d0b677b --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/AnimatedIconLayerRenderer.kt @@ -0,0 +1,89 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering.layers + +import org.jetbrains.icons.rendering.Bounds +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.PaintingApi +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.impl.layers.AnimatedIconLayer +import org.jetbrains.icons.impl.rendering.layers.IconLayerManager +import org.jetbrains.icons.impl.IconAnimationFrame +import org.jetbrains.icons.impl.rendering.modifiers.applyTo + +class AnimatedIconLayerRenderer( + private val layer: AnimatedIconLayer, + private val renderingContext: RenderingContext +) : IconLayerRenderer { + private val currentContext = renderingContext.adjustTo(layer) + private val frameRenderers = layer.frames.map { AnimatedIconFrameRenderer(it, currentContext) } + private var lastFrame = FrameData(System.currentTimeMillis(), 0, 0) + + override fun render(api: PaintingApi) { + val layout = DefaultLayerLayout( + Bounds( + 0, + 0, + api.bounds.width, + api.bounds.height, + ), + api.bounds + ) + val nestedLayerApi = api.withCustomContext(layer.modifier.applyTo(layout, api.scaling).calculateFinalBounds()) + val currentFrameData = calculateAndSetNewFrameData() + frameRenderers[currentFrameData.frame].render(nestedLayerApi) + if (currentFrameData.remainingDuration > 0L) { + renderingContext.updateFlow.triggerDelayedUpdate(currentFrameData.remainingDuration) + } + } + + override fun calculateExpectedDimensions(scaling: ScalingContext): Dimensions { + return frameRenderers[lastFrame.frame].calculateExpectedDimensions(scaling) + } + + private fun calculateAndSetNewFrameData(): FrameData { + val currentLastFrame = lastFrame + val elapsedMillis = System.currentTimeMillis() - lastFrame.timestamp + if (elapsedMillis > currentLastFrame.remainingDuration) { + val index = (currentLastFrame.frame + 1) % frameRenderers.size + val remaining = frameRenderers[index].frame.duration + val newData = FrameData(System.currentTimeMillis(), index, remaining) + lastFrame = newData + return newData + } else return lastFrame + } + + private class FrameData( + val timestamp: Long, + val frame: Int, + val remainingDuration: Long + ) + + private class AnimatedIconFrameRenderer( + val frame: IconAnimationFrame, + renderingContext: RenderingContext + ) { + private val renderers = IconLayerManager.createRenderers(frame.layers, renderingContext) + + fun render(api: PaintingApi) { + for (layer in renderers) { + layer.render(api) + } + } + + fun calculateExpectedDimensions(scalingContext: ScalingContext): Dimensions { + var width = 0 + var height = 0 + for (layer in renderers) { + val dimensions = layer.calculateExpectedDimensions(scalingContext) + if (dimensions.width > width) { + width = dimensions.width + } + if (dimensions.height > height) { + height = dimensions.height + } + } + return Dimensions(width, height) + } + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/DefaultLayerLayout.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/DefaultLayerLayout.kt new file mode 100644 index 0000000000000..0a9160b7e7555 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/DefaultLayerLayout.kt @@ -0,0 +1,91 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering.layers + +import org.jetbrains.icons.design.Color +import org.jetbrains.icons.filters.ColorFilter +import org.jetbrains.icons.rendering.Bounds +import kotlin.hashCode + +interface LayerLayout { + val layerBounds: Bounds + val parentBounds: Bounds + val colorFilter: ColorFilter? + val alpha: Float + val cutoutMargin: Float? + val stroke: Color? + + fun copy( + layerBounds: Bounds = this.layerBounds, + parentBounds: Bounds = this.parentBounds, + colorFilter: ColorFilter? = this.colorFilter, + alpha: Float = this.alpha, + cutoutMargin: Float? = this.cutoutMargin, + stroke: Color? = this.stroke + ): LayerLayout + + fun calculateFinalBounds(): Bounds +} + +open class DefaultLayerLayout( + override val layerBounds: Bounds, + override val parentBounds: Bounds, + override val colorFilter: ColorFilter? = null, + override val alpha: Float = 1f, + override val cutoutMargin: Float? = null, + override val stroke: Color? = null +): LayerLayout { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DefaultLayerLayout + + if (alpha != other.alpha) return false + if (layerBounds != other.layerBounds) return false + if (parentBounds != other.parentBounds) return false + if (colorFilter != other.colorFilter) return false + if (cutoutMargin != other.cutoutMargin) return false + if (stroke != other.stroke) return false + + return true + } + + override fun hashCode(): Int { + var result = alpha.hashCode() + result = 31 * result + layerBounds.hashCode() + result = 31 * result + parentBounds.hashCode() + result = 31 * result + (colorFilter?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "LayerLayout(layerBounds=$layerBounds, parentBounds=$parentBounds, colorFilter=$colorFilter, alpha=$alpha, stroke=$stroke)" + } + + override fun calculateFinalBounds(): Bounds { + return Bounds( + layerBounds.x + parentBounds.x, + layerBounds.y + parentBounds.y, + layerBounds.width, + layerBounds.height + ) + } + + override fun copy( + layerBounds: Bounds, + parentBounds: Bounds, + colorFilter: ColorFilter?, + alpha: Float, + cutoutMargin: Float?, + stroke: Color? + ): DefaultLayerLayout { + return DefaultLayerLayout( + layerBounds, + parentBounds, + colorFilter, + alpha, + cutoutMargin, + stroke + ) + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/IconIconLayerRenderer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/IconIconLayerRenderer.kt new file mode 100644 index 0000000000000..a5f0b3f0a446b --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/IconIconLayerRenderer.kt @@ -0,0 +1,37 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering.layers + +import org.jetbrains.icons.rendering.Bounds +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.PaintingApi +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.rendering.createRenderer +import org.jetbrains.icons.impl.layers.IconIconLayer +import org.jetbrains.icons.impl.rendering.modifiers.applyTo + +class IconIconLayerRenderer( + private val layer: IconIconLayer, + private val renderingContext: RenderingContext +) : IconLayerRenderer { + private val renderer = layer.icon.createRenderer(renderingContext.adjustTo(layer)) + + override fun render(api: PaintingApi) { + val layout = DefaultLayerLayout( + Bounds( + 0, + 0, + api.bounds.width, + api.bounds.height, + ), + api.bounds + ) + val appliedLayout = layer.modifier.applyTo(layout, api.scaling) + val boundApi = api.withCustomContext(appliedLayout.calculateFinalBounds()) + renderer.render(boundApi) + } + + override fun calculateExpectedDimensions(scaling: ScalingContext): Dimensions { + return renderer.calculateExpectedDimensions(scaling) + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/IconLayerManager.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/IconLayerManager.kt new file mode 100644 index 0000000000000..c03a6d15c9690 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/IconLayerManager.kt @@ -0,0 +1,29 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering.layers + +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.rendering.RenderingContext + +interface IconLayerManager { + fun createRenderer(layer: IconLayer, renderingContext: RenderingContext): IconLayerRenderer + + companion object { + @Volatile + private var instance: IconLayerManager? = null + + @JvmStatic + fun getInstance(): IconLayerManager = instance ?: error("IconLayerRendererManager is not initialized") + + fun setInstance(manager: IconLayerManager) { + instance = manager + } + + fun createRenderers( + layers: List, + renderingContext: RenderingContext, + ): List { + val instance = getInstance() + return layers.map { instance.createRenderer(it, renderingContext) } + } + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/IconLayerRenderer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/IconLayerRenderer.kt new file mode 100644 index 0000000000000..a5f9ff45ebfc8 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/IconLayerRenderer.kt @@ -0,0 +1,12 @@ +// 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.icons.impl.rendering.layers + +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.PaintingApi +import org.jetbrains.icons.rendering.ScalingContext + +interface IconLayerRenderer { + fun render(api: PaintingApi) + fun calculateExpectedDimensions(scaling: ScalingContext): Dimensions +} + diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/ImageIconLayerRenderer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/ImageIconLayerRenderer.kt new file mode 100644 index 0000000000000..4ab215d400cee --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/ImageIconLayerRenderer.kt @@ -0,0 +1,48 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering.layers + +import org.jetbrains.icons.rendering.Bounds +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.ImageResource +import org.jetbrains.icons.rendering.PaintingApi +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.rendering.imageResource +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.impl.layers.ImageIconLayer +import org.jetbrains.icons.impl.rendering.layers.applyTo +import org.jetbrains.icons.impl.rendering.modifiers.applyTo + +class ImageIconLayerRenderer( + override val layer: ImageIconLayer, + override val renderingContext: RenderingContext +) : BaseImageIconLayerRenderer() { + override var image: ImageResource = imageResource(layer.loader, layer.generateImageModifiers(renderingContext)) + override fun calculateExpectedDimensions(scaling: ScalingContext): Dimensions { + return Dimensions(scaling.applyTo(image.width ?: 16), scaling.applyTo(image.height ?: 16)) + } +} + +abstract class BaseImageIconLayerRenderer: IconLayerRenderer { + abstract var image: ImageResource + abstract val layer: IconLayer + protected abstract val renderingContext: RenderingContext + + override fun render(api: PaintingApi) { + val currentImage = image + val w = api.scaling.applyTo(currentImage.width) + val h = api.scaling.applyTo(currentImage.height) + val layout = DefaultLayerLayout( + Bounds( + 0, + 0, + api.bounds.width.coerceAtMost(w ?: Integer.MAX_VALUE), + api.bounds.height.coerceAtMost(h ?: Integer.MAX_VALUE) + ), + api.bounds + ) + val appliedLayout = layer.modifier.applyTo(layout, api.scaling) + val finalBounds = appliedLayout.calculateFinalBounds() + api.drawImage(currentImage, finalBounds.x, finalBounds.y, finalBounds.width, finalBounds.height, alpha = appliedLayout.alpha) + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/LayerHelpers.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/LayerHelpers.kt new file mode 100644 index 0000000000000..aa159c4651b0a --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/LayerHelpers.kt @@ -0,0 +1,41 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering.layers + +import org.jetbrains.icons.modifiers.ColorFilterModifier +import org.jetbrains.icons.modifiers.StrokeModifier +import org.jetbrains.icons.modifiers.SvgPatcherModifier +import org.jetbrains.icons.rendering.ImageModifiers +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.layers.IconLayer +import org.jetbrains.icons.impl.layers.findModifier +import org.jetbrains.icons.impl.rendering.DefaultImageModifiers +import kotlin.math.ceil + +fun IconLayer.generateImageModifiers(renderingContext: RenderingContext? = null): ImageModifiers { + val defaults = renderingContext?.defaultImageModifiers + val knownDefaults = defaults as? DefaultImageModifiers + return DefaultImageModifiers( + colorFilter = findModifier()?.colorFilter ?: defaults?.colorFilter, + svgPatcher = findModifier()?.svgPatcher ?: defaults?.svgPatcher, + isDark = knownDefaults?.isDark ?: false, + stroke = findModifier()?.color ?: knownDefaults?.stroke + ) +} + +fun RenderingContext.adjustTo(layer: IconLayer): RenderingContext { + return copy(defaultImageModifiers = layer.generateImageModifiers(this)) +} + +fun ScalingContext.applyTo(px: Int): Int { + return applyTo(px.toDouble()) +} + +fun ScalingContext.applyTo(px: Double): Int { + return ceil(px * display).toInt() +} + +fun ScalingContext.applyTo(px: Int?): Int? { + if (px == null) return null + return applyTo(px) +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/LayoutIconLayerRenderer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/LayoutIconLayerRenderer.kt new file mode 100644 index 0000000000000..21f5c4debaf39 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/LayoutIconLayerRenderer.kt @@ -0,0 +1,100 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering.layers + +import org.jetbrains.icons.rendering.Bounds +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.PaintingApi +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.impl.rendering.layers.IconLayerManager +import org.jetbrains.icons.impl.layers.LayoutIconLayer +import org.jetbrains.icons.impl.rendering.modifiers.applyTo +import org.jetbrains.icons.impl.rendering.modifiers.asPixels +import kotlin.compareTo + +class LayoutIconLayerRenderer( + private val layoutLayer: LayoutIconLayer, + renderingContext: RenderingContext, +) : IconLayerRenderer { + private val currentContext = renderingContext.adjustTo(layoutLayer) + private val nestedRenderers = createNestedRenderers(layoutLayer) + + private fun createNestedRenderers(layer: LayoutIconLayer): List { + return IconLayerManager.createRenderers(layer.nestedLayers, currentContext) + } + + override fun render(api: PaintingApi) { + val layout = DefaultLayerLayout( + Bounds( + 0, + 0, + api.bounds.width, + api.bounds.height, + ), + api.bounds, + ) + val appliedLayout = layoutLayer.modifier.applyTo(layout, api.scaling) + val finalBounds = appliedLayout.calculateFinalBounds() + + when (layoutLayer.direction) { + LayoutIconLayer.LayoutDirection.Row -> { + val spacingPx = layoutLayer.spacing.asPixels(api.scaling, api.bounds, true) + val remainingSize = finalBounds.width - spacingPx * (nestedRenderers.count() - 1) + val size = remainingSize / nestedRenderers.count() + var offset = 0 + for (nestedRenderer in nestedRenderers) { + val nestedApi = api.withCustomContext(Bounds(finalBounds.x + offset, finalBounds.y, size, finalBounds.height)) + nestedRenderer.render(nestedApi) + offset += size + spacingPx + } + } + LayoutIconLayer.LayoutDirection.Column -> { + val spacingPx = layoutLayer.spacing.asPixels(api.scaling, api.bounds, false) + val remainingSize = finalBounds.height - spacingPx * (nestedRenderers.count() - 1) + val size = remainingSize / nestedRenderers.count() + var offset = 0 + for (nestedRenderer in nestedRenderers) { + val nestedApi = api.withCustomContext(Bounds(finalBounds.x, finalBounds.y + offset, finalBounds.width, size)) + nestedRenderer.render(nestedApi) + offset += size + spacingPx + } + } + else -> { + for (nestedRenderer in nestedRenderers) { + nestedRenderer.render(api) + } + } + } + } + + override fun calculateExpectedDimensions(scaling: ScalingContext): Dimensions { + var width = 0 + var height = 0 + for (layer in nestedRenderers) { + val dimensions = layer.calculateExpectedDimensions(scaling) + when (layoutLayer.direction) { + LayoutIconLayer.LayoutDirection.Row -> { + width += dimensions.width + if (dimensions.height > height) { + height = dimensions.height + } + } + LayoutIconLayer.LayoutDirection.Column -> { + height += dimensions.height + if (dimensions.width > width) { + width = dimensions.width + } + } + else -> { + if (dimensions.width > width) { + width = dimensions.width + } + if (dimensions.height > height) { + height = dimensions.height + } + } + } + } + return Dimensions(width, height) + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/ShapeIconLayerRenderer.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/ShapeIconLayerRenderer.kt new file mode 100644 index 0000000000000..b7a8f895cdb10 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/layers/ShapeIconLayerRenderer.kt @@ -0,0 +1,70 @@ +// 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.icons.impl.rendering.layers + +import org.jetbrains.icons.design.Circle +import org.jetbrains.icons.design.Rectangle +import org.jetbrains.icons.rendering.Bounds +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.DrawMode +import org.jetbrains.icons.rendering.PaintingApi +import org.jetbrains.icons.rendering.RenderingContext +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.impl.layers.ShapeIconLayer +import org.jetbrains.icons.impl.rendering.modifiers.applyTo +import kotlin.math.roundToInt + +class ShapeIconLayerRenderer( + val layer: ShapeIconLayer, + val renderingContext: RenderingContext +) : IconLayerRenderer { + override fun render(api: PaintingApi) { + val layout = DefaultLayerLayout( + Bounds( + 0, + 0, + api.bounds.width, + api.bounds.height, + ), + api.bounds + ) + val appliedLayout = layer.modifier.applyTo(layout, api.scaling) + val finalBounds = appliedLayout.calculateFinalBounds() + val cutoutMargin = appliedLayout.cutoutMargin ?: 0f + when (layer.shape) { + Circle -> { + val radius = finalBounds.width.coerceAtMost(finalBounds.height) / 2.0f + val roundRadius = radius.roundToInt() + if (cutoutMargin > 0f) { + api.drawCircle( + layer.color, + finalBounds.x + roundRadius, + finalBounds.y + roundRadius, + radius + cutoutMargin + 1, + 1f, + DrawMode.Clear + ) + } + api.drawCircle(layer.color, finalBounds.x + roundRadius, finalBounds.y + roundRadius, radius, 1f) + } + Rectangle -> { + if (cutoutMargin > 0f) { + val cutoutMargin2 = cutoutMargin + cutoutMargin + api.drawRect( + layer.color, + finalBounds.x - cutoutMargin.roundToInt(), + finalBounds.y - cutoutMargin.roundToInt(), + cutoutMargin2.roundToInt(), + cutoutMargin2.roundToInt(), + 1f, + DrawMode.Clear + ) + } + api.drawRect(layer.color, finalBounds.x, finalBounds.y, finalBounds.width, finalBounds.height, 1f) + } + } + } + + override fun calculateExpectedDimensions(scaling: ScalingContext): Dimensions { + return Dimensions(0, 0) + } +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/modifiers/ColorFilterModifier.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/modifiers/ColorFilterModifier.kt new file mode 100644 index 0000000000000..362d7268986b3 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/modifiers/ColorFilterModifier.kt @@ -0,0 +1,9 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering.modifiers + +import org.jetbrains.icons.modifiers.ColorFilterModifier +import org.jetbrains.icons.impl.rendering.layers.DefaultLayerLayout + +internal fun applyColorFilterModifier(modifier: ColorFilterModifier, layout: DefaultLayerLayout, displayScale: Float): DefaultLayerLayout { + return layout.copy(colorFilter = modifier.colorFilter) +} diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/modifiers/IconModifier.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/modifiers/IconModifier.kt new file mode 100644 index 0000000000000..fc652da4539ed --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/modifiers/IconModifier.kt @@ -0,0 +1,120 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering.modifiers + +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.MaxIconUnit +import org.jetbrains.icons.design.DisplayPointIconUnit +import org.jetbrains.icons.design.IconUnit +import org.jetbrains.icons.design.IconVerticalAlign +import org.jetbrains.icons.design.IconHorizontalAlign +import org.jetbrains.icons.design.PercentIconUnit +import org.jetbrains.icons.design.PixelIconUnit +import org.jetbrains.icons.modifiers.AlignIconModifier +import org.jetbrains.icons.modifiers.AlphaIconModifier +import org.jetbrains.icons.modifiers.ColorFilterModifier +import org.jetbrains.icons.modifiers.CombinedIconModifier +import org.jetbrains.icons.modifiers.CutoutMarginModifier +import org.jetbrains.icons.modifiers.HeightIconModifier +import org.jetbrains.icons.modifiers.IconModifier +import org.jetbrains.icons.modifiers.MarginIconModifier +import org.jetbrains.icons.modifiers.StrokeModifier +import org.jetbrains.icons.modifiers.SvgPatcherModifier +import org.jetbrains.icons.modifiers.WidthIconModifier +import org.jetbrains.icons.rendering.Bounds +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.impl.rendering.layers.LayerLayout +import org.jetbrains.icons.impl.rendering.layers.applyTo +import kotlin.math.roundToInt + +@ApiStatus.Internal +fun IconModifier.applyTo(layout: LayerLayout, scaling: ScalingContext): LayerLayout { + return when (this) { + is CombinedIconModifier -> { + other.applyTo(root.applyTo(layout, scaling), scaling) + } + is WidthIconModifier -> { + layout.copy( + layerBounds = layout.layerBounds.copy( + width = width.asPixels(scaling, layout.parentBounds, true) + ) + ) + } + is HeightIconModifier -> { + layout.copy( + layerBounds = layout.layerBounds.copy( + height = height.asPixels(scaling, layout.parentBounds, false) + ) + ) + } + is AlignIconModifier -> { + val x = when (align.horizontalAlign) { + IconHorizontalAlign.Left -> { + layout.layerBounds.x + } + IconHorizontalAlign.Right -> { + layout.parentBounds.width - layout.layerBounds.width + } + IconHorizontalAlign.Center -> { + (layout.parentBounds.width / 2) - (layout.layerBounds.width / 2) + } + } + val y = when (align.verticalAlign) { + IconVerticalAlign.Top -> { + layout.layerBounds.y + } + IconVerticalAlign.Bottom -> { + layout.parentBounds.height - layout.layerBounds.height + } + IconVerticalAlign.Center -> { + (layout.parentBounds.height / 2) - (layout.layerBounds.height / 2) + } + } + layout.copy( + layerBounds = layout.layerBounds.copy( + x = x, + y = y + ) + ) + } + is MarginIconModifier -> applyMarginIconModifier(this, layout, scaling) + is AlphaIconModifier -> layout.copy(alpha = alpha) + is ColorFilterModifier -> layout // applyColorFilterModifier(this, layout, displayScale) // ImageModifier + is SvgPatcherModifier -> layout // ImageModifier + is CutoutMarginModifier -> applyCutoutMarginModifier(this, layout, scaling) + is StrokeModifier -> layout + IconModifier.Companion -> layout + } +} + +fun applyStrokeModifier(modifier: StrokeModifier, layout: LayerLayout): LayerLayout { + return layout.copy(stroke = modifier.color) +} + +fun applyCutoutMarginModifier(modifier: CutoutMarginModifier, layout: LayerLayout, scaling: ScalingContext): LayerLayout { + val final = layout.calculateFinalBounds() + val size = modifier.size.asFractionalPixels(scaling, final, true) + return layout.copy(cutoutMargin = size) +} + +fun IconUnit.asFractionalPixels(scaling: ScalingContext, bounds: Bounds, isWidth: Boolean = false): Float { + return when (this) { + is PixelIconUnit -> value + is PercentIconUnit -> if (isWidth) { + bounds.width * value + } else { + bounds.height * value + } + is DisplayPointIconUnit -> scaling.applyTo(value) + is MaxIconUnit -> { + if (isWidth) { + bounds.width + } else { + bounds.height + } + } + }.toFloat() +} + +fun IconUnit.asPixels(scaling: ScalingContext, bounds: Bounds, isWidth: Boolean = false): Int { + return asFractionalPixels(scaling, bounds, isWidth).roundToInt() +} \ No newline at end of file diff --git a/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/modifiers/MarginIconModifier.kt b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/modifiers/MarginIconModifier.kt new file mode 100644 index 0000000000000..7427d1fb03c60 --- /dev/null +++ b/platform/icons-impl/src/org/jetbrains/icons/impl/rendering/modifiers/MarginIconModifier.kt @@ -0,0 +1,25 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.icons.impl.rendering.modifiers + +import org.jetbrains.icons.modifiers.MarginIconModifier +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.icons.impl.rendering.layers.LayerLayout + +internal fun applyMarginIconModifier(modifier: MarginIconModifier, layout: LayerLayout, scaling: ScalingContext): LayerLayout { + val leftPx = modifier.left.asPixels(scaling, layout.parentBounds, true) + val topPx = modifier.top.asPixels(scaling, layout.parentBounds, false) + val rightPx = modifier.right.asPixels(scaling, layout.parentBounds, true) + val bottomPx = modifier.bottom.asPixels(scaling, layout.parentBounds, false) + + val finalX = maxOf(layout.layerBounds.x, leftPx) + val finalY = maxOf(layout.layerBounds.y, topPx) + + return layout.copy( + layerBounds = layout.layerBounds.copy( + x = finalX, + y = finalY, + width = minOf(layout.parentBounds.width - finalX - rightPx, layout.layerBounds.width), + height = minOf(layout.parentBounds.height - finalY - bottomPx, layout.layerBounds.height) + ) + ) +} diff --git a/platform/ide-core-impl/src/com/intellij/ui/icons/CoreIconManager.kt b/platform/ide-core-impl/src/com/intellij/ui/icons/CoreIconManager.kt index 2cc8cc2e6a51b..5bf8abb38ee24 100644 --- a/platform/ide-core-impl/src/com/intellij/ui/icons/CoreIconManager.kt +++ b/platform/ide-core-impl/src/com/intellij/ui/icons/CoreIconManager.kt @@ -249,6 +249,9 @@ class CoreIconManager : IconManager, CoreAwareIconManager { return hasher.asLong } + /** + * Ensure to also change ModuleImageResourceLocation in icons-impl/intellij + */ override fun getPluginAndModuleId(classLoader: ClassLoader): Pair { if (classLoader is PluginAwareClassLoader) { return classLoader.pluginId.idString to classLoader.moduleId @@ -258,6 +261,9 @@ class CoreIconManager : IconManager, CoreAwareIconManager { } } + /** + * Ensure to also change ModuleImageResourceLoader in icons-impl/intellij/rendering + */ override fun getClassLoader(pluginId: String, moduleId: String?): ClassLoader? { val plugin = PluginManagerCore.findPlugin(PluginId.getId(pluginId)) ?: return null if (moduleId == null) { diff --git a/platform/ide-core/resources/META-INF/intellij.moduleSets.core.platform.xml b/platform/ide-core/resources/META-INF/intellij.moduleSets.core.platform.xml index 7e4afe5b8938e..349830d11cf60 100644 --- a/platform/ide-core/resources/META-INF/intellij.moduleSets.core.platform.xml +++ b/platform/ide-core/resources/META-INF/intellij.moduleSets.core.platform.xml @@ -118,6 +118,7 @@ + diff --git a/platform/jewel/build.gradle.kts b/platform/jewel/build.gradle.kts index 07dc37ee3c1eb..c52e74aa98200 100644 --- a/platform/jewel/build.gradle.kts +++ b/platform/jewel/build.gradle.kts @@ -6,6 +6,8 @@ plugins { `jewel-linting` } + + tasks { register("clean") { delete(rootProject.layout.buildDirectory) } diff --git a/platform/jewel/gradle/libs.versions.toml b/platform/jewel/gradle/libs.versions.toml index abb7e33534606..927cdea6bc99e 100644 --- a/platform/jewel/gradle/libs.versions.toml +++ b/platform/jewel/gradle/libs.versions.toml @@ -17,6 +17,7 @@ kotlin = "2.2.20" kotlinpoet = "2.1.0" kotlinterGradlePlugin = "5.2.0" kotlinxSerialization = "1.9.0" +kotlinxCoroutines = "1.10.2" ktfmtGradlePlugin = "0.23.0" metalava = "1.0.0-alpha13" jsoup = "1.21.2" @@ -43,8 +44,9 @@ junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "jun junit-platform-engine = { module = "org.junit.platform:junit-platform-engine", version.ref = "junitPlatform" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatform" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } -kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core-jvm", version.ref = "kotlinxSerialization" } ktor-client-java = { module = "io.ktor:ktor-client-java", version = "3.0.3" } metalava = { module = "com.android.tools.metalava:metalava", version.ref = "metalava" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } 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 0d5e8eefba380..3406d6c88ef5b 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 @@ -75,7 +75,7 @@ public fun JewelComposePanel( SwingBridgeTheme { CompositionLocalProvider( LocalComponentFoundation provides this@createJewelComposePanel, - LocalPopupRenderer provides JBPopupRenderer, + LocalPopupRenderer provides JBPopupRenderer ) { ComponentDataProviderBridge(jewelPanel, content = content) } diff --git a/platform/jewel/ide-laf-bridge/src/main/resources/intellij.platform.jewel.ideLafBridge.xml b/platform/jewel/ide-laf-bridge/src/main/resources/intellij.platform.jewel.ideLafBridge.xml index 2212d890c6c5b..9cb39b531c856 100644 --- a/platform/jewel/ide-laf-bridge/src/main/resources/intellij.platform.jewel.ideLafBridge.xml +++ b/platform/jewel/ide-laf-bridge/src/main/resources/intellij.platform.jewel.ideLafBridge.xml @@ -1,7 +1,9 @@ - - - + + + + + diff --git a/platform/jewel/int-ui/int-ui-standalone/BUILD.bazel b/platform/jewel/int-ui/int-ui-standalone/BUILD.bazel index 4f6699ecedb36..130728a6e92c2 100644 --- a/platform/jewel/int-ui/int-ui-standalone/BUILD.bazel +++ b/platform/jewel/int-ui/int-ui-standalone/BUILD.bazel @@ -38,6 +38,8 @@ jvm_library( "//platform/jewel/foundation", "//libraries/jbr", "@lib//:jna", + "//platform/icons-api", + "//platform/icons-impl", ], plugins = ["@lib//:compose-plugin"] ) @@ -57,6 +59,8 @@ jvm_library( "//libraries/compose-runtime-desktop:compose-runtime-desktop_test_lib", "//platform/jewel/foundation:foundation_test_lib", "//libraries/jbr:jbr_test_lib", + "//platform/icons-api:icons-api_test_lib", + "//platform/icons-impl:icons-impl_test_lib", ], plugins = ["@lib//:compose-plugin"] ) diff --git a/platform/jewel/int-ui/int-ui-standalone/build.gradle.kts b/platform/jewel/int-ui/int-ui-standalone/build.gradle.kts index 7defc34d3b345..226c8cb01a834 100644 --- a/platform/jewel/int-ui/int-ui-standalone/build.gradle.kts +++ b/platform/jewel/int-ui/int-ui-standalone/build.gradle.kts @@ -13,6 +13,9 @@ plugins { dependencies { api(projects.ui) + api(project(":jb-icons-api")) + api(project(":jb-icons-api-rendering")) + api(project(":jb-icons-impl")) implementation(libs.jbr.api) implementation(libs.jna.core) } diff --git a/platform/jewel/int-ui/int-ui-standalone/intellij.platform.jewel.intUi.standalone.iml b/platform/jewel/int-ui/int-ui-standalone/intellij.platform.jewel.intUi.standalone.iml index 37d796bbcb81e..353c9fd888bfc 100644 --- a/platform/jewel/int-ui/int-ui-standalone/intellij.platform.jewel.intUi.standalone.iml +++ b/platform/jewel/int-ui/int-ui-standalone/intellij.platform.jewel.intUi.standalone.iml @@ -42,5 +42,7 @@ + + \ No newline at end of file diff --git a/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/icon/StandaloneIconDesigner.kt b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/icon/StandaloneIconDesigner.kt new file mode 100644 index 0000000000000..66d7af7b6e99a --- /dev/null +++ b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/icon/StandaloneIconDesigner.kt @@ -0,0 +1,14 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.intui.standalone.icon + +import org.jetbrains.icons.impl.design.DefaultIconDesigner +import org.jetbrains.icons.modifiers.IconModifier +import org.jetbrains.jewel.ui.icon.PathImageResourceLocation + +internal class StandaloneIconDesigner : DefaultIconDesigner() { + override fun image(path: String, classLoader: ClassLoader?, modifier: IconModifier) { + image(PathImageResourceLocation(path, classLoader), modifier) + } + + override fun createNestedDesigner(): StandaloneIconDesigner = StandaloneIconDesigner() +} diff --git a/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/icon/StandaloneIconManager.kt b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/icon/StandaloneIconManager.kt new file mode 100644 index 0000000000000..808972654342c --- /dev/null +++ b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/icon/StandaloneIconManager.kt @@ -0,0 +1,29 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.intui.standalone.icon + +import kotlinx.coroutines.CoroutineScope +import org.jetbrains.icons.Icon +import org.jetbrains.icons.IconIdentifier +import org.jetbrains.icons.design.IconDesigner +import org.jetbrains.icons.impl.DefaultIconManager +import org.jetbrains.icons.impl.DeferredIconResolverService + +internal class StandaloneIconManager(scope: CoroutineScope) : DefaultIconManager() { + override val resolverService: DeferredIconResolverService = DeferredIconResolverService(scope) + + override suspend fun sendDeferredNotifications(id: IconIdentifier, result: Icon) { + // Do nothing + } + + override fun markDeferredIconUnused(id: IconIdentifier) { + resolverService.cleanIcon(id) + } + + override fun icon(designer: IconDesigner.() -> Unit): Icon { + val iconDesigner = StandaloneIconDesigner() + iconDesigner.designer() + return iconDesigner.build() + } +} + +internal class StandaloneDeferredIconResolverService(scope: CoroutineScope) : DeferredIconResolverService(scope) diff --git a/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/icon/StandaloneIconRendererManager.kt b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/icon/StandaloneIconRendererManager.kt new file mode 100644 index 0000000000000..556c217c43c2a --- /dev/null +++ b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/icon/StandaloneIconRendererManager.kt @@ -0,0 +1,45 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +package org.jetbrains.jewel.intui.standalone.icon + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import org.jetbrains.icons.Icon +import org.jetbrains.icons.impl.rendering.CoroutineBasedMutableIconUpdateFlow +import org.jetbrains.icons.impl.rendering.DefaultIconRendererManager +import org.jetbrains.icons.rendering.ImageModifiers +import org.jetbrains.icons.rendering.MutableIconUpdateFlow +import org.jetbrains.icons.rendering.RenderingContext + +internal class StandaloneIconRendererManager : DefaultIconRendererManager() { + override fun createUpdateFlow(scope: CoroutineScope?, updateCallback: (Int) -> Unit): MutableIconUpdateFlow { + if (scope == null) return EmptyMutableIconUpdateFlow() + return CoroutineBasedMutableIconUpdateFlow(scope, updateCallback) + } + + override fun createRenderingContext( + updateFlow: MutableIconUpdateFlow, + defaultImageModifiers: ImageModifiers?, + ): RenderingContext { + return RenderingContext(updateFlow, defaultImageModifiers) + } +} + +private class EmptyMutableIconUpdateFlow : MutableIconUpdateFlow { + override fun triggerUpdate() { + // Do nothing + } + + override fun triggerDelayedUpdate(delay: Long) { + // Do nothing + } + + override suspend fun collect(collector: FlowCollector) { + // Do nothing + } + + override fun collectDynamic(flow: Flow, handler: (Icon) -> Unit) { + // Do nothing + } +} diff --git a/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt index 8e390b5498221..25a41d4feabf2 100644 --- a/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt +++ b/platform/jewel/int-ui/int-ui-standalone/src/main/kotlin/org/jetbrains/jewel/intui/standalone/theme/IntUiTheme.kt @@ -5,8 +5,10 @@ package org.jetbrains.jewel.intui.standalone.theme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle +import org.jetbrains.icons.IconManager import org.jetbrains.jewel.foundation.DisabledAppearanceValues import org.jetbrains.jewel.foundation.GlobalColors import org.jetbrains.jewel.foundation.GlobalMetrics @@ -20,6 +22,7 @@ import org.jetbrains.jewel.intui.standalone.IntUiMessageResourceResolver import org.jetbrains.jewel.intui.standalone.IntUiTypography import org.jetbrains.jewel.intui.standalone.StandalonePainterHintsProvider import org.jetbrains.jewel.intui.standalone.StandalonePlatformCursorController +import org.jetbrains.jewel.intui.standalone.icon.StandaloneIconManager import org.jetbrains.jewel.intui.standalone.icon.StandaloneNewUiChecker import org.jetbrains.jewel.intui.standalone.menuShortcut.StandaloneMenuItemShortcutHintProvider import org.jetbrains.jewel.intui.standalone.menuShortcut.StandaloneShortcutProvider @@ -1075,6 +1078,8 @@ public fun IntUiTheme( swingCompatMode: Boolean = false, content: @Composable () -> Unit, ) { + val managerScope = rememberCoroutineScope() + IconManager.activate(StandaloneIconManager(managerScope)) BaseJewelTheme(theme, ComponentStyling.default().with(styling), swingCompatMode) { CompositionLocalProvider( LocalPainterHintsProvider provides StandalonePainterHintsProvider(theme), diff --git a/platform/jewel/int-ui/int-ui-standalone/src/main/resources/META-INF/services/org.jetbrains.icons.rendering.IconRendererManager b/platform/jewel/int-ui/int-ui-standalone/src/main/resources/META-INF/services/org.jetbrains.icons.rendering.IconRendererManager new file mode 100644 index 0000000000000..8ea2a0a84dfc4 --- /dev/null +++ b/platform/jewel/int-ui/int-ui-standalone/src/main/resources/META-INF/services/org.jetbrains.icons.rendering.IconRendererManager @@ -0,0 +1 @@ +org.jetbrains.jewel.intui.standalone.icon.StandaloneIconRendererManager diff --git a/platform/jewel/int-ui/int-ui-standalone/src/main/resources/META-INF/services/org.jetbrains.icons.rendering.ImageResourceProvider b/platform/jewel/int-ui/int-ui-standalone/src/main/resources/META-INF/services/org.jetbrains.icons.rendering.ImageResourceProvider new file mode 100644 index 0000000000000..e94e176216edf --- /dev/null +++ b/platform/jewel/int-ui/int-ui-standalone/src/main/resources/META-INF/services/org.jetbrains.icons.rendering.ImageResourceProvider @@ -0,0 +1 @@ +org.jetbrains.jewel.ui.icon.ComposeImageResourceProvider diff --git a/platform/jewel/int-ui/int-ui-standalone/src/main/resources/intellij.platform.jewel.intUi.standalone.xml b/platform/jewel/int-ui/int-ui-standalone/src/main/resources/intellij.platform.jewel.intUi.standalone.xml index 038db731b91b5..ef1eb97a61734 100644 --- a/platform/jewel/int-ui/int-ui-standalone/src/main/resources/intellij.platform.jewel.intUi.standalone.xml +++ b/platform/jewel/int-ui/int-ui-standalone/src/main/resources/intellij.platform.jewel.intUi.standalone.xml @@ -1,6 +1,8 @@ - + + + diff --git a/platform/jewel/samples/showcase/BUILD.bazel b/platform/jewel/samples/showcase/BUILD.bazel index b839524d27971..873fe8d6f28e2 100644 --- a/platform/jewel/samples/showcase/BUILD.bazel +++ b/platform/jewel/samples/showcase/BUILD.bazel @@ -9,6 +9,7 @@ create_kotlinc_options( "androidx.compose.foundation.ExperimentalFoundationApi", "org.jetbrains.jewel.foundation.ExperimentalJewelApi", "org.jetbrains.jewel.foundation.InternalJewelApi", + "org.jetbrains.icons.ExperimentalIconsApi", ], plugin_options = ["plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaAnnotations=true"], x_context_parameters = True, diff --git a/platform/jewel/samples/showcase/api-dump.txt b/platform/jewel/samples/showcase/api-dump.txt index 907bffeb202f9..66c6bda6f2d84 100644 --- a/platform/jewel/samples/showcase/api-dump.txt +++ b/platform/jewel/samples/showcase/api-dump.txt @@ -2,11 +2,15 @@ f:org.jetbrains.jewel.samples.showcase.ShowcaseIcons - sf:$stable:I - sf:INSTANCE:org.jetbrains.jewel.samples.showcase.ShowcaseIcons - f:getComponentsMenu():org.jetbrains.jewel.ui.icon.PathIconKey -- f:getGitHub():org.jetbrains.jewel.ui.icon.PathIconKey -- f:getJewelLogo():org.jetbrains.jewel.ui.icon.PathIconKey +- f:getGitHub():org.jetbrains.icons.Icon +- f:getGitHubKey():org.jetbrains.jewel.ui.icon.PathIconKey +- f:getJewelLogo():org.jetbrains.icons.Icon +- f:getJewelLogoKey():org.jetbrains.jewel.ui.icon.PathIconKey +- f:getLayeredIcon():org.jetbrains.icons.Icon - f:getMarkdown():org.jetbrains.jewel.ui.icon.PathIconKey - f:getSunny():org.jetbrains.jewel.ui.icon.PathIconKey -- f:getThemeDark():org.jetbrains.jewel.ui.icon.PathIconKey +- f:getThemeDark():org.jetbrains.icons.Icon +- f:getThemeDarkKey():org.jetbrains.jewel.ui.icon.PathIconKey - f:getThemeLight():org.jetbrains.jewel.ui.icon.PathIconKey - f:getThemeLightWithLightHeader():org.jetbrains.jewel.ui.icon.PathIconKey - f:getThemeSystem():org.jetbrains.jewel.ui.icon.PathIconKey diff --git a/platform/jewel/samples/showcase/exposed-third-party-api.txt b/platform/jewel/samples/showcase/exposed-third-party-api.txt index 168de8a3b0bb9..9f3f1e91d6e47 100644 --- a/platform/jewel/samples/showcase/exposed-third-party-api.txt +++ b/platform/jewel/samples/showcase/exposed-third-party-api.txt @@ -1,2 +1,3 @@ androidx/compose/** kotlin/jvm/internal/DefaultConstructorMarker +org/jetbrains/icons/api/Icon diff --git a/platform/jewel/samples/showcase/intellij.platform.jewel.samples.showcase.iml b/platform/jewel/samples/showcase/intellij.platform.jewel.samples.showcase.iml index f95ea989b5614..bb519850b1005 100644 --- a/platform/jewel/samples/showcase/intellij.platform.jewel.samples.showcase.iml +++ b/platform/jewel/samples/showcase/intellij.platform.jewel.samples.showcase.iml @@ -4,7 +4,7 @@ - diff --git a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/ShowcaseIcons.kt b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/ShowcaseIcons.kt index 02bd15b92f536..cbc18260e37bd 100644 --- a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/ShowcaseIcons.kt +++ b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/ShowcaseIcons.kt @@ -1,12 +1,22 @@ // 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.samples.showcase +import org.jetbrains.icons.Icon +import org.jetbrains.icons.design.IconAlign +import org.jetbrains.icons.design.percent +import org.jetbrains.icons.icon +import org.jetbrains.icons.imageIcon +import org.jetbrains.icons.modifiers.IconModifier +import org.jetbrains.icons.modifiers.align +import org.jetbrains.icons.modifiers.fillMaxSize +import org.jetbrains.icons.modifiers.margin +import org.jetbrains.icons.modifiers.size import org.jetbrains.jewel.ui.icon.PathIconKey public object ShowcaseIcons { public val componentsMenu: PathIconKey = PathIconKey("icons/structure.svg", ShowcaseIcons::class.java) - public val gitHub: PathIconKey = PathIconKey("icons/github.svg", ShowcaseIcons::class.java) public val jewelLogo: PathIconKey = PathIconKey("icons/jewel-logo.svg", ShowcaseIcons::class.java) + public val gitHub: PathIconKey = PathIconKey("icons/github.svg", ShowcaseIcons::class.java) public val markdown: PathIconKey = PathIconKey("icons/markdown.svg", ShowcaseIcons::class.java) public val themeDark: PathIconKey = PathIconKey("icons/darkTheme.svg", ShowcaseIcons::class.java) public val themeLight: PathIconKey = PathIconKey("icons/lightTheme.svg", ShowcaseIcons::class.java) diff --git a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/Icons.kt b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/Icons.kt index 3e1e0769339b9..cc85e7e7bd1da 100644 --- a/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/Icons.kt +++ b/platform/jewel/samples/showcase/src/main/kotlin/org/jetbrains/jewel/samples/showcase/components/Icons.kt @@ -24,12 +24,18 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.unit.dp +import org.jetbrains.icons.design.Circle +import org.jetbrains.icons.modifiers.IconModifier import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.samples.showcase.ShowcaseIcons import org.jetbrains.jewel.ui.component.CheckboxRow import org.jetbrains.jewel.ui.component.Icon import org.jetbrains.jewel.ui.component.Image import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icon.badge +import org.jetbrains.jewel.ui.icon.iconKey +import org.jetbrains.jewel.ui.icon.size +import org.jetbrains.jewel.ui.icon.stroke import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.jewel.ui.painter.badge.DotBadgeShape import org.jetbrains.jewel.ui.painter.hints.Badge @@ -106,6 +112,55 @@ public fun Icons(modifier: Modifier = Modifier) { } } + Column { + Text("Icon Modifiers & Layers: (new api)") + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) { + Icon(contentDescription = "taskGroup") { + iconKey(AllIconsKeys.Nodes.ConfigFolder) + } + } + Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) { + Icon(contentDescription = "taskGroup") { + iconKey(AllIconsKeys.Nodes.ConfigFolder) + badge(Color.Red, Circle) + } + } + val backgroundColor = + if (JewelTheme.isDark) { + JewelTheme.colorPalette.blueOrNull(4) ?: Color(0xFF375FAD) + } else { + JewelTheme.colorPalette.blueOrNull(4) ?: Color(0xFF3574F0) + } + Box( + Modifier.size(24.dp).background(backgroundColor, shape = RoundedCornerShape(4.dp)), + contentAlignment = Alignment.Center, + ) { + Icon(contentDescription = "taskGroup") { + iconKey(AllIconsKeys.Nodes.ConfigFolder, modifier = IconModifier.stroke(Color.White)) + } + } + Box( + Modifier.size(24.dp).background(backgroundColor, shape = RoundedCornerShape(4.dp)), + contentAlignment = Alignment.Center, + ) { + Icon(contentDescription = "taskGroup") { + iconKey(AllIconsKeys.Nodes.ConfigFolder, modifier = IconModifier.stroke(Color.White)) + badge(Color.Red, Circle) + } + } + Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) { + Icon(contentDescription = "taskGroup") { + iconKey(AllIconsKeys.Nodes.ConfigFolder, modifier = IconModifier.size(20.dp)) + } + } + } + } + Column { var checked by remember { mutableStateOf(true) } diff --git a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt index d541dc423cf39..6ddb2500679f2 100644 --- a/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt +++ b/platform/jewel/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/TitleBarView.kt @@ -12,6 +12,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import java.awt.Desktop import java.net.URI +import org.jetbrains.icons.design.px +import org.jetbrains.icons.icon +import org.jetbrains.icons.modifiers.IconModifier +import org.jetbrains.icons.modifiers.size import org.jetbrains.jewel.samples.showcase.ShowcaseIcons import org.jetbrains.jewel.samples.showcase.views.forCurrentOs import org.jetbrains.jewel.samples.standalone.IntUiThemes @@ -117,7 +121,7 @@ internal fun DecoratedWindowScope.TitleBarView() { ) IntUiThemes.Dark -> - Icon(key = ShowcaseIcons.themeDark, contentDescription = "Dark", hints = arrayOf(Size(20))) + Icon(ShowcaseIcons.themeDark, contentDescription = "Dark") IntUiThemes.System -> Icon( diff --git a/platform/jewel/settings.gradle.kts b/platform/jewel/settings.gradle.kts index 2931a57bff13f..1b7165d7cc2c4 100644 --- a/platform/jewel/settings.gradle.kts +++ b/platform/jewel/settings.gradle.kts @@ -51,8 +51,16 @@ include( ":samples:standalone", ":ui", ":ui-tests", + ":jb-icons-api", + ":jb-icons-api-rendering", + ":jb-icons-impl" ) +project(":jb-icons-api").projectDir = file("../icons-api") +project(":jb-icons-api-rendering").projectDir = file("../icons-api/rendering") +project(":jb-icons-impl").projectDir = file("../icons-impl") + + develocity { buildScan { publishing.onlyIf { System.getenv("CI") == "true" } diff --git a/platform/jewel/ui/BUILD.bazel b/platform/jewel/ui/BUILD.bazel index e49e1987ff17b..fa68743554ee3 100644 --- a/platform/jewel/ui/BUILD.bazel +++ b/platform/jewel/ui/BUILD.bazel @@ -43,12 +43,17 @@ jvm_library( "@lib//:platform-jewel-ui-org-jetbrains-compose-components-components-resources-desktop", "//libraries/compose-foundation-desktop", "//libraries/compose-runtime-desktop", + "//platform/icons-api", + "//platform/icons-api/rendering", + "//platform/icons-impl", ], exports = [ "//platform/jewel/foundation", "@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", + "//platform/icons-api", + "//platform/icons-api/rendering", ], plugins = ["@lib//:compose-plugin"] ) @@ -77,6 +82,12 @@ jvm_library( "//libraries/compose-runtime-desktop:compose-runtime-desktop_test_lib", "//libraries/junit4", "//libraries/junit4:junit4_test_lib", + "//platform/icons-api", + "//platform/icons-api:icons-api_test_lib", + "//platform/icons-api/rendering", + "//platform/icons-api/rendering:rendering_test_lib", + "//platform/icons-impl", + "//platform/icons-impl:icons-impl_test_lib", "//libraries/compose-foundation-desktop-junit", "//libraries/compose-foundation-desktop-junit:compose-foundation-desktop-junit_test_lib", ], @@ -87,6 +98,10 @@ jvm_library( "@lib//:platform-jewel-ui-org-jetbrains-compose-components-components-resources-desktop", "//libraries/compose-foundation-desktop", "//libraries/compose-foundation-desktop:compose-foundation-desktop_test_lib", + "//platform/icons-api", + "//platform/icons-api:icons-api_test_lib", + "//platform/icons-api/rendering", + "//platform/icons-api/rendering:rendering_test_lib", ], plugins = ["@lib//:compose-plugin"] ) diff --git a/platform/jewel/ui/api-dump-experimental.txt b/platform/jewel/ui/api-dump-experimental.txt index 0529b6ab214f5..92d5c7a262891 100644 --- a/platform/jewel/ui/api-dump-experimental.txt +++ b/platform/jewel/ui/api-dump-experimental.txt @@ -96,6 +96,14 @@ f:org.jetbrains.jewel.ui.component.search.SpeedSearchableTreeKt - *sf:SpeedSearchableTree(org.jetbrains.jewel.ui.component.SpeedSearchScope,org.jetbrains.jewel.foundation.lazy.tree.Tree,kotlin.jvm.functions.Function1,androidx.compose.ui.Modifier,org.jetbrains.jewel.foundation.lazy.tree.TreeState,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function1,org.jetbrains.jewel.foundation.lazy.tree.KeyActions,org.jetbrains.jewel.ui.component.styling.LazyTreeStyle,kotlinx.coroutines.CoroutineDispatcher,kotlin.jvm.functions.Function4,androidx.compose.runtime.Composer,I,I,I):V f:org.jetbrains.jewel.ui.component.styling.IconButtonStylingKt - *sf:getLocalTransparentIconButtonStyle():androidx.compose.runtime.ProvidableCompositionLocal +f:org.jetbrains.jewel.ui.icon.IconUtilsKt +- *sf:tintColor-4MyTwCo(org.jetbrains.icons.api.modifiers.IconModifier,J,I):org.jetbrains.icons.api.modifiers.IconModifier +- *bs:tintColor-4MyTwCo$default(org.jetbrains.icons.api.modifiers.IconModifier,J,I,I,java.lang.Object):org.jetbrains.icons.api.modifiers.IconModifier +- *sf:toCompose(org.jetbrains.icons.api.design.BlendMode):I +- *sf:toCompose(org.jetbrains.icons.api.design.Color):J +- *sf:toCompose(org.jetbrains.icons.api.filters.ColorFilter):androidx.compose.ui.graphics.ColorFilter +- *sf:toIconsBlendMode-s9anfk8(I):org.jetbrains.icons.api.design.BlendMode +- *sf:toIconsColor-8_81llA(J):org.jetbrains.icons.api.design.RGBA *f:org.jetbrains.jewel.ui.painter.hints.EmbeddedToInlineCssStyleSvgPatchHint - org.jetbrains.jewel.ui.painter.PainterSvgPatchHint - sf:$stable:I diff --git a/platform/jewel/ui/api-dump.txt b/platform/jewel/ui/api-dump.txt index e7d09117cd855..450312e1eabd7 100644 --- a/platform/jewel/ui/api-dump.txt +++ b/platform/jewel/ui/api-dump.txt @@ -381,6 +381,8 @@ f:org.jetbrains.jewel.ui.component.IconButtonState$Companion - bs:of-3OtLUoY$default(org.jetbrains.jewel.ui.component.IconButtonState$Companion,Z,Z,Z,Z,Z,I,java.lang.Object):J f:org.jetbrains.jewel.ui.component.IconKt - sf:Icon(androidx.compose.ui.graphics.painter.Painter,java.lang.String,androidx.compose.ui.graphics.ColorFilter,androidx.compose.ui.Modifier,androidx.compose.runtime.Composer,I,I):V +- sf:Icon(java.lang.String,androidx.compose.ui.Modifier,org.jetbrains.icons.rendering.LoadingStrategy,kotlin.jvm.functions.Function1,androidx.compose.runtime.Composer,I,I):V +- sf:Icon(org.jetbrains.icons.Icon,java.lang.String,androidx.compose.ui.Modifier,org.jetbrains.icons.rendering.LoadingStrategy,androidx.compose.runtime.Composer,I,I):V - sf:Icon(org.jetbrains.jewel.ui.icon.IconKey,java.lang.String,androidx.compose.ui.Modifier,java.lang.Class,androidx.compose.ui.graphics.ColorFilter,org.jetbrains.jewel.ui.painter.PainterHint,androidx.compose.runtime.Composer,I,I):V - sf:Icon(org.jetbrains.jewel.ui.icon.IconKey,java.lang.String,androidx.compose.ui.Modifier,java.lang.Class,androidx.compose.ui.graphics.ColorFilter,org.jetbrains.jewel.ui.painter.PainterHint[],androidx.compose.runtime.Composer,I,I):V - sf:Icon-FHprtrg(org.jetbrains.jewel.ui.icon.IconKey,java.lang.String,androidx.compose.ui.Modifier,java.lang.Class,J,org.jetbrains.jewel.ui.painter.PainterHint,androidx.compose.runtime.Composer,I,I):V @@ -2360,6 +2362,10 @@ f:org.jetbrains.jewel.ui.graphics.CssLinearGradientBrushKt org.jetbrains.jewel.ui.icon.IconKey - a:getIconClass():java.lang.Class - a:path(Z):java.lang.String +f:org.jetbrains.jewel.ui.icon.IconKeyKt +- sf:iconKey(org.jetbrains.icons.design.IconDesigner,org.jetbrains.jewel.ui.icon.IconKey,org.jetbrains.icons.modifiers.IconModifier):V +- bs:iconKey$default(org.jetbrains.icons.design.IconDesigner,org.jetbrains.jewel.ui.icon.IconKey,org.jetbrains.icons.modifiers.IconModifier,I,java.lang.Object):V +f:org.jetbrains.jewel.ui.icon.IconUtilsKt f:org.jetbrains.jewel.ui.icon.IntelliJIconKey - org.jetbrains.jewel.ui.icon.IconKey - sf:$stable:I diff --git a/platform/jewel/ui/build.gradle.kts b/platform/jewel/ui/build.gradle.kts index 0c10b7c6a557b..87569ff734071 100644 --- a/platform/jewel/ui/build.gradle.kts +++ b/platform/jewel/ui/build.gradle.kts @@ -13,6 +13,9 @@ plugins { dependencies { api(projects.foundation) + api(project(":jb-icons-api")) + api(project(":jb-icons-api-rendering")) + api(project(":jb-icons-impl")) implementation(compose.components.resources) testImplementation(compose.desktop.uiTestJUnit4) testImplementation(compose.desktop.currentOs) { exclude(group = "org.jetbrains.compose.material") } diff --git a/platform/jewel/ui/exposed-third-party-api.txt b/platform/jewel/ui/exposed-third-party-api.txt index cbaeb3beca2d6..e855ffd4be5ac 100644 --- a/platform/jewel/ui/exposed-third-party-api.txt +++ b/platform/jewel/ui/exposed-third-party-api.txt @@ -1,4 +1,5 @@ androidx/compose/** kotlin/jvm/internal/DefaultConstructorMarker kotlin/ranges/ClosedFloatingPointRange +org/jetbrains/icons/api/** org/w3c/dom/Element diff --git a/platform/jewel/ui/intellij.platform.jewel.ui.iml b/platform/jewel/ui/intellij.platform.jewel.ui.iml index d34fb87e2ae61..19206e157e095 100644 --- a/platform/jewel/ui/intellij.platform.jewel.ui.iml +++ b/platform/jewel/ui/intellij.platform.jewel.ui.iml @@ -82,6 +82,9 @@ + + + \ 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..4bd76d72c8248 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 @@ -7,7 +7,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.paint import androidx.compose.ui.geometry.Size @@ -31,9 +35,17 @@ 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.Icon +import org.jetbrains.icons.design.IconDesigner +import org.jetbrains.icons.icon +import org.jetbrains.icons.rendering.IconRendererManager +import org.jetbrains.icons.rendering.LoadingStrategy +import org.jetbrains.icons.rendering.createRenderer +import org.jetbrains.icons.impl.rendering.DefaultImageModifiers 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.RendererBasedIconPainter import org.jetbrains.jewel.ui.icon.newUiChecker import org.jetbrains.jewel.ui.painter.PainterHint import org.jetbrains.jewel.ui.painter.rememberResourcePainterProvider @@ -76,11 +88,51 @@ public fun Icon( * always be provided unless this icon is used for decorative purposes, and does not represent a meaningful action * that a user can take. * @param modifier optional [Modifier] for this Icon. - * @param iconClass The class to use for resolving the icon resource. Defaults to `key.iconClass`. - * @param tint tint to be applied to the icon. If [Color.Unspecified] is provided, then no tint is applied. - * @param hint [PainterHint] to be passed to the painter. + * @param loadingStrategy specifies if the function should block the thread while loading the icon or show placeholder + * or render blank instead. */ -@Suppress("ComposableParamOrder") // To fix in JEWEL-929 +@Composable +public fun Icon( + icon: Icon, + contentDescription: String?, + modifier: Modifier = Modifier, + loadingStrategy: LoadingStrategy = LoadingStrategy.BlockThread +) { + val scope = rememberCoroutineScope() + val scalingContext = RendererBasedIconPainter.inferScalingContext() + var updateIndex by remember { mutableStateOf(0) } + val context = IconRendererManager.createRenderingContext( + updateFlow = IconRendererManager.createUpdateFlow(scope) { update -> + updateIndex = update + }, + defaultImageModifiers = DefaultImageModifiers( + isDark = JewelTheme.isDark + ) + ) + val renderer = remember(icon) { + icon.createRenderer(context) + } + val painter = remember(icon, renderer) { + RendererBasedIconPainter( + renderer, + scaling = scalingContext + ) + } + key(updateIndex) { + Icon(painter = painter, contentDescription = contentDescription, modifier = modifier) + } +} + +@Composable +public fun Icon( + contentDescription: String?, + modifier: Modifier = Modifier, + loadingStrategy: LoadingStrategy = LoadingStrategy.BlockThread, + designer: IconDesigner.() -> Unit +) { + Icon(icon(designer), contentDescription, modifier, loadingStrategy) +} + @Composable public fun Icon( key: IconKey, @@ -298,8 +350,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/ComposeBitmapImageResource.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposeBitmapImageResource.kt new file mode 100644 index 0000000000000..e26324f9d888d --- /dev/null +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposeBitmapImageResource.kt @@ -0,0 +1,145 @@ +// Copyright 2000-2026 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.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import androidx.compose.ui.graphics.asSkiaBitmap +import org.jetbrains.icons.rendering.BitmapImageResource +import org.jetbrains.icons.rendering.Bounds +import org.jetbrains.icons.rendering.lowlevel.GPUImageResourceHolder +import org.jetbrains.icons.rendering.ImageScale +import org.jetbrains.icons.rendering.RescalableImageResource +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ImageInfo +import org.jetbrains.icons.impl.rendering.CachedGPUImageResourceHolder +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.impl.BufferUtil + +internal class ComposeBitmapImageResource( + public val imageBitmap: ImageBitmap +): BitmapImageResource, CachedGPUImageResourceHolder() { + override fun getRGBPixels(): IntArray { + val skia = imageBitmap.asSkiaBitmap() + val pixelsNativePointer = skia.peekPixels()!!.addr + val pixelsBuffer = BufferUtil.getByteBufferFromPointer(pixelsNativePointer, skia.rowBytes * skia.height) + return pixelsBuffer.asIntBuffer().array() + } + + override fun readPrefetchedPixel(pixels: IntArray, x: Int, y: Int): Int? { + return pixels.getOrNull(y * imageBitmap.width + x) + } + + override fun getBandOffsetsToSRGB(): IntArray { + val skia = imageBitmap.asSkiaBitmap() + return when (skia.colorInfo.colorType) { + ColorType.RGB_888X -> intArrayOf(0, 1, 2, 3) + ColorType.BGRA_8888 -> intArrayOf(2, 1, 0, 3) + else -> throw UnsupportedOperationException("unsupported color type ${skia.colorInfo.colorType}") + } + } + + override val width: Int = imageBitmap.width + override val height: Int = imageBitmap.height +} + +internal fun BitmapImageResource.composeBitmap(): ImageBitmap { + if (this is ComposeBitmapImageResource) return imageBitmap + val cache = (this as? GPUImageResourceHolder) + return cache?.getOrGenerateBitmap(ImageBitmap::class) { + composeBitmapWithoutCaching().second + } ?: composeBitmapWithoutCaching().second +} + +internal fun RescalableImageResource.composeBitmap(scale: ImageScale): ImageBitmapView { + val cache = (this as? GPUImageResourceHolder) + val cached = cache?.getOrGenerateBitmap(SingleBitmapCache::class) { SingleBitmapCache() } + if (cached != null) { + return cached.getOrPut(this, scale) + } else { + return scale(scale).composeBitmap().toView() + } +} + +internal fun ImageBitmap.toView(): ImageBitmapView { + return ImageBitmapView(this, Dimensions(this.width, this.height)) +} + +internal class ImageBitmapView( + val imageBitmap: ImageBitmap, + val size: Dimensions +) + +private class SingleBitmapCache { + private var lastBitmap: CachedBitmap? = null + + private class CachedBitmap( + val dimensions: Bounds, + var composeBitmap: ImageBitmapView, + val bitmap: Bitmap + ) + + fun getOrPut(image: RescalableImageResource, scale: ImageScale): ImageBitmapView { + val last = lastBitmap + val expectedDimensions = image.calculateExpectedDimensions(scale) + if (last == null) { + return createNewBitmap(image, scale, expectedDimensions) + } else { + if (last.composeBitmap.size == expectedDimensions) { + return last.composeBitmap + } else if (last.dimensions.canFit(expectedDimensions)) { + last.bitmap.setPixelsFrom(image.scale(scale)) + last.composeBitmap = last.composeBitmap.adjustTo(expectedDimensions) + return last.composeBitmap + } else { + return createNewBitmap(image, scale, expectedDimensions) + } + } + } + + private fun createNewBitmap(image: RescalableImageResource, imageScale: ImageScale, dimensions: Bounds): ImageBitmapView { + val (skia, compose) = image.scale(imageScale).composeBitmapWithoutCaching() + val new = CachedBitmap( + dimensions, + compose.toView(), + skia + ) + lastBitmap = new + return new.composeBitmap + } +} + +private fun ImageBitmapView.adjustTo(dimensions: Dimensions): ImageBitmapView { + return ImageBitmapView(imageBitmap, dimensions) +} + +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) + val prefetchedPixels = image.getRGBPixels() + + var k = 0 + for (y in 0 until height) { + for (x in 0 until width) { + val argb = image.readPrefetchedPixel(prefetchedPixels, 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/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposeImageResourceProvider.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposeImageResourceProvider.kt new file mode 100644 index 0000000000000..0769389be795a --- /dev/null +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposeImageResourceProvider.kt @@ -0,0 +1,137 @@ +// Copyright 2000-2026 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.ui.graphics.Color +import androidx.compose.ui.graphics.decodeToImageBitmap +import androidx.compose.ui.unit.Density +import java.io.ByteArrayInputStream +import java.io.InputStream +import javax.xml.XMLConstants +import javax.xml.parsers.DocumentBuilderFactory +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.compose.resources.decodeToSvgPainter +import org.jetbrains.icons.modifiers.svgPatcher +import org.jetbrains.icons.patchers.SvgPatchOperation +import org.jetbrains.icons.patchers.SvgPatcher +import org.jetbrains.icons.patchers.combineWith +import org.jetbrains.icons.rendering.ImageModifiers +import org.jetbrains.icons.rendering.ImageResource +import org.jetbrains.icons.ImageResourceLocation +import org.jetbrains.icons.rendering.ImageResourceProvider +import org.jetbrains.icons.impl.rendering.DefaultImageModifiers +import org.jetbrains.jewel.foundation.InternalJewelApi +import org.jetbrains.jewel.ui.painter.writeToString +import org.w3c.dom.Element + +@InternalJewelApi +@ApiStatus.Internal +public class ComposeImageResourceProvider : ImageResourceProvider { + override fun loadImage(location: ImageResourceLocation, imageModifiers: ImageModifiers?): ImageResource { + // TODO Support image modifiers + if (location is PathImageResourceLocation) { + val extension = location.path.substringAfterLast(".").lowercase() + val data = location.loadData(imageModifiers) + val stream = ByteArrayInputStream(data) + return when (extension) { + "svg" -> ComposePainterImageResource(patchSvg(imageModifiers, stream).decodeToSvgPainter(Density(1f)), imageModifiers) + // "xml" -> loader.loadData().decodeToImageVector() + else -> ComposeBitmapImageResource(location.loadData(imageModifiers).decodeToImageBitmap()) + } + } else { + error("Unsupported loader: $location") + } + } +} + +private val documentBuilderFactory = + DocumentBuilderFactory.newDefaultInstance().apply { setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) } + +private fun patchSvg(modifiers: ImageModifiers?, inputStream: InputStream): ByteArray { +val builder = documentBuilderFactory.newDocumentBuilder() + val document = builder.parse(inputStream) + + val knownModifiers = modifiers as? DefaultImageModifiers + val patcher = knownModifiers?.stroke?.let { stroke -> + svgPatcher { + for (color in backgroundPalette) { + replaceIfMatches("fill", color.toIconsColor().toHex(), "transparent") + } + for (color in strokeColors) { + replaceIfMatches("fill", color.toIconsColor().toHex(), stroke.toHex()) + } + } + } combineWith modifiers?.svgPatcher + patcher?.patch(document.documentElement) + return document.writeToString().toByteArray() +} + +private val backgroundPalette = + listOf( + Color(0xFFEBECF0), + Color(0xFFE7EFFD), + Color(0xFFDFF2E0), + Color(0xFFF2FCF3), + Color(0xFFFFE8E8), + Color(0xFFFFF5F5), + Color(0xFFFFF8E3), + Color(0xFFFFF4EB), + Color(0xFFEEE0FF), + ) + +private val strokeColors = + listOf( + Color(0xFF000000), + Color(0xFFFFFFFF), + Color(0xFF818594), + Color(0xFF6C707E), + Color(0xFF3574F0), + Color(0xFF5FB865), + Color(0xFFE35252), + Color(0xFFEB7171), + Color(0xFFE3AE4D), + Color(0xFFFCC75B), + Color(0xFFF28C35), + Color(0xFF955AE0), + ) + +private fun SvgPatcher.patch(element: Element) { + for (operation in operations) { + when (operation.operation) { + SvgPatchOperation.Operation.Add -> { + if (!element.hasAttribute(operation.attributeName)) { + element.setAttribute(operation.attributeName, operation.value!!) + } + } + SvgPatchOperation.Operation.Replace -> { + if (operation.conditional) { + val matches = + element.getAttribute(operation.attributeName).equals(operation.expectedValue, ignoreCase = true) + if (matches == !operation.negatedCondition) { + element.setAttribute(operation.attributeName, operation.value!!) + } + } else if (element.hasAttribute(operation.attributeName)) { + element.setAttribute(operation.attributeName, operation.value!!) + } + } + SvgPatchOperation.Operation.Remove -> { + if (operation.conditional) { + val matches = element.getAttribute(operation.attributeName) == operation.expectedValue + if (matches == !operation.negatedCondition) { + element.removeAttribute(operation.attributeName) + } + } else { + element.removeAttribute(operation.attributeName) + } + } + SvgPatchOperation.Operation.Set -> element.setAttribute(operation.attributeName, operation.value!!) + } + } + val nodes = element.childNodes + val length = nodes.length + for (i in 0 until length) { + val item = nodes.item(i) + if (item is Element) { + patch(item) + } + } +} diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposePainterImageResource.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposePainterImageResource.kt new file mode 100644 index 0000000000000..9dfba9e281591 --- /dev/null +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposePainterImageResource.kt @@ -0,0 +1,15 @@ +// Copyright 2000-2026 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.ui.graphics.painter.Painter +import androidx.compose.ui.util.fastRoundToInt +import org.jetbrains.icons.rendering.ImageModifiers +import org.jetbrains.icons.rendering.ImageResource + +internal class ComposePainterImageResource( + val painter: Painter, + val modifiers: ImageModifiers? +): ImageResource { + override val width: Int = painter.intrinsicSize.width.fastRoundToInt() + override val height: Int = painter.intrinsicSize.height.fastRoundToInt() +} diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposePaintingApi.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposePaintingApi.kt new file mode 100644 index 0000000000000..9cc8a3594a73d --- /dev/null +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposePaintingApi.kt @@ -0,0 +1,232 @@ +// Copyright 2000-2026 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.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultBlendMode +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.filters.ColorFilter +import org.jetbrains.icons.rendering.BitmapImageResource +import org.jetbrains.icons.rendering.Bounds +import org.jetbrains.icons.rendering.FitAreaScale +import org.jetbrains.icons.rendering.ImageResource +import org.jetbrains.icons.rendering.RescalableImageResource +import org.jetbrains.icons.rendering.PaintingApi +import org.jetbrains.jewel.foundation.InternalJewelApi +import kotlin.math.roundToInt +import org.jetbrains.icons.design.Color +import org.jetbrains.icons.rendering.DrawMode +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.jewel.foundation.GenerateDataFunctions + +@InternalJewelApi +@ApiStatus.Internal +public class ComposePaintingApi( + public val drawScope: DrawScope, + private val customBounds: Bounds? = null, + private val overrideColorFilter: ColorFilter? = null, + override val scaling: ScalingContext = ComposeScalingContext(drawScope.drawContext.density.density) +): PaintingApi { + private var usedBounds: Bounds? = null + + override val bounds: Bounds = customBounds ?: Bounds( + 0, + 0, + drawScope.size.width.roundToInt(), + drawScope.size.height.roundToInt() + ) + + override fun withCustomContext( + bounds: Bounds, + overrideColorFilter: ColorFilter? + ): PaintingApi { + return ComposePaintingApi(drawScope, bounds, overrideColorFilter ?: this.overrideColorFilter) + } + + override fun drawCircle(color: Color, x: Int, y: Int, radius: Float, alpha: Float, mode: DrawMode) { + val style = if (mode == DrawMode.Stroke) { + Fill + } else Fill // TODO Support strokes + val blendMode = if (mode == DrawMode.Clear) { + BlendMode.Clear + } else DefaultBlendMode + drawScope.drawCircle( + color.toCompose(), + radius, + Offset(x.toFloat(), y.toFloat()), + alpha, + style = style, + blendMode = blendMode + ) + } + + override fun drawRect(color: Color, x: Int, y: Int, width: Int, height: Int, alpha: Float, mode: DrawMode) { + val style = if (mode == DrawMode.Stroke) { + Fill + } else Fill // TODO Support strokes + val blendMode = if (mode == DrawMode.Clear) { + BlendMode.Clear + } else DefaultBlendMode + drawScope.drawRect( + color.toCompose(), + Offset(x.toFloat(), y.toFloat()), + Size(width.toFloat(), height.toFloat()), + alpha, + style = style, + blendMode = blendMode + ) + } + + override fun drawImage( + image: ImageResource, + x: Int, + y: Int, + width: Int?, + height: Int?, + srcX: Int, + srcY: Int, + srcWidth: Int?, + srcHeight: Int?, + alpha: Float, + colorFilter: ColorFilter? + ) { + when (image) { + is BitmapImageResource -> { + drawImage(image, x, y, width, height, srcX, srcY, srcWidth, srcHeight, alpha, colorFilter) + } + is RescalableImageResource -> { + drawImage(image, x, y, width, height, srcX, srcY, srcWidth, srcHeight, alpha, colorFilter) + } + is ComposePainterImageResource -> { + drawImage(image, x, y, width, height, srcX, srcY, srcWidth, srcHeight, alpha, colorFilter) + } + } + } + + override fun getUsedBounds(): Bounds = usedBounds ?: bounds + + private fun drawImage( + image: BitmapImageResource, + x: Int, + y: Int, + width: Int?, + height: Int?, + srcX: Int, + srcY: Int, + srcWidth: Int?, + srcHeight: Int?, + alpha: Float, + colorFilter: ColorFilter? + ) { + drawComposeImage(image.composeBitmap().toView(), x, y, width, height, srcX, srcY, srcWidth, srcHeight, alpha, colorFilter) + } + + private fun drawImage( + image: RescalableImageResource, + x: Int, + y: Int, + width: Int?, + height: Int?, + srcX: Int, + srcY: Int, + srcWidth: Int?, + srcHeight: Int?, + alpha: Float, + colorFilter: ColorFilter? + ) { + val scaledImage = image.composeBitmap(FitAreaScale(width ?: bounds.width, height ?: bounds.width)) + drawComposeImage(scaledImage, x, y, width, height, srcX, srcY, srcWidth, srcHeight, alpha, colorFilter) + } + + private fun drawImage( + image: ComposePainterImageResource, + x: Int, + y: Int, + width: Int?, + height: Int?, + srcX: Int, + srcY: Int, + srcWidth: Int?, + srcHeight: Int?, + alpha: Float, + colorFilter: ColorFilter? + ) { + if (image.width == 0 || image.height == 0) return + val targetSize = IntSize(width ?: bounds.width, height ?: bounds.height) + drawScope.translate(x.toFloat(), y.toFloat()) { + with(image.painter) { + draw( + Size( + targetSize.width.toFloat(), + targetSize.height.toFloat() + ), + alpha, + convertColorFilter(colorFilter ?: image.modifiers?.colorFilter) + ) + } + } + } + + private fun drawComposeImage( + image: ImageBitmapView, + x: Int, + y: Int, + width: Int?, + height: Int?, + srcX: Int, + srcY: Int, + srcWidth: Int?, + srcHeight: Int?, + alpha: Float, + colorFilter: ColorFilter? = null + ) { + if (image.imageBitmap.width == 0 || image.imageBitmap.height == 0) return + if (image.size.width == 0 || image.size.height == 0) return + val targetSize = IntSize(width ?: image.size.width, height ?: image.size.height) + if (targetSize.width == 0 || targetSize.height == 0) return + drawScope.drawImage( + image.imageBitmap, + IntOffset(srcX, srcY), + IntSize(srcWidth ?: image.size.width, srcHeight ?: image.size.height), + dstOffset = IntOffset(x, y), + dstSize = targetSize, + alpha = alpha, + colorFilter = convertColorFilter(colorFilter), + filterQuality = FilterQuality.Low + ) + } + + private fun convertColorFilter(colorFilter: ColorFilter?): androidx.compose.ui.graphics.ColorFilter? { + return (overrideColorFilter ?: colorFilter)?.toCompose() + } +} + +@GenerateDataFunctions +internal class ComposeScalingContext( + override val display: Float +) : ScalingContext { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ComposeScalingContext + + return display == other.display + } + + override fun hashCode(): Int { + return display.hashCode() + } + + override fun toString(): String { + return "ComposeScalingContext(display=$display)" + } +} diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconKey.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconKey.kt index 8aaa8bbc0ad2f..3abfba01e95bd 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconKey.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconKey.kt @@ -1,5 +1,8 @@ package org.jetbrains.jewel.ui.icon +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.IconDesigner +import org.jetbrains.icons.modifiers.IconModifier import org.jetbrains.jewel.foundation.GenerateDataFunctions public interface IconKey { @@ -8,6 +11,11 @@ public interface IconKey { public fun path(isNewUi: Boolean): String } +@ApiStatus.Experimental +public fun IconDesigner.iconKey(iconKey: IconKey, modifier: IconModifier = IconModifier) { + image(iconKey.path(isNewUi = true), iconKey.iconClass.classLoader, modifier) +} + @GenerateDataFunctions public class PathIconKey(private val path: String, override val iconClass: Class<*>) : IconKey { override fun path(isNewUi: Boolean): String = path diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconUtils.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconUtils.kt new file mode 100644 index 0000000000000..7170adebd6411 --- /dev/null +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/IconUtils.kt @@ -0,0 +1,103 @@ +// Copyright 2000-2026 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.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.design.IconAlign +import org.jetbrains.icons.design.Shape +import org.jetbrains.icons.design.IconDesigner +import org.jetbrains.icons.design.IconUnit +import org.jetbrains.icons.design.RGBA +import org.jetbrains.icons.design.badge +import org.jetbrains.icons.design.dp +import org.jetbrains.icons.design.relativeTo +import org.jetbrains.icons.filters.ColorFilter +import org.jetbrains.icons.filters.TintColorFilter +import org.jetbrains.icons.modifiers.IconModifier +import org.jetbrains.icons.modifiers.size +import org.jetbrains.icons.modifiers.stroke +import org.jetbrains.icons.modifiers.tintColor +import org.jetbrains.jewel.foundation.ExperimentalJewelApi + +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun IconModifier.tintColor(composeColor: Color, blendMode: BlendMode = BlendMode.SrcIn): IconModifier { + return tintColor(composeColor.toIconsColor(), blendMode.toIconsBlendMode()) +} + +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun IconModifier.stroke(composeColor: Color): IconModifier { + return stroke(composeColor.toIconsColor()) +} + +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun IconModifier.size(size: Dp): IconModifier { + return size(size.value.dp) +} + + +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun IconDesigner.badge( + color: Color, + shape: Shape, + size: IconUnit = (3.5 * 2).dp relativeTo 20.dp, + align: IconAlign = IconAlign.TopRight, + cutout: IconUnit = 1.5.dp relativeTo 20.dp, + modifier: IconModifier = IconModifier, +) { + badge(color.toIconsColor(), shape, size, align, cutout, modifier) +} + +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun ColorFilter.toCompose(): androidx.compose.ui.graphics.ColorFilter { + return when (this) { + is TintColorFilter -> androidx.compose.ui.graphics.ColorFilter.tint(color.toCompose(), blendMode.toCompose()) + } +} + +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun org.jetbrains.icons.design.Color.toCompose(): Color { + return when (this) { + is RGBA -> Color(red, green, blue, alpha) + } +} + +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun org.jetbrains.icons.design.BlendMode.toCompose(): BlendMode { + return when (this) { + org.jetbrains.icons.design.BlendMode.SrcIn -> BlendMode.SrcIn + org.jetbrains.icons.design.BlendMode.Color -> BlendMode.Color + org.jetbrains.icons.design.BlendMode.Hue -> BlendMode.Hue + org.jetbrains.icons.design.BlendMode.Luminosity -> BlendMode.Luminosity + org.jetbrains.icons.design.BlendMode.Saturation -> BlendMode.Saturation + org.jetbrains.icons.design.BlendMode.Multiply -> BlendMode.Multiply + } +} + +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun BlendMode.toIconsBlendMode(): org.jetbrains.icons.design.BlendMode { + return when (this) { + BlendMode.SrcIn -> org.jetbrains.icons.design.BlendMode.SrcIn + BlendMode.Color -> org.jetbrains.icons.design.BlendMode.Color + BlendMode.Hue -> org.jetbrains.icons.design.BlendMode.Hue + BlendMode.Luminosity -> org.jetbrains.icons.design.BlendMode.Luminosity + BlendMode.Saturation -> org.jetbrains.icons.design.BlendMode.Saturation + BlendMode.Multiply -> org.jetbrains.icons.design.BlendMode.Multiply + else -> error("Unsupported Compose blend mode $this") + } +} + +@ExperimentalJewelApi +@ApiStatus.Experimental +public fun Color.toIconsColor(): RGBA { + return RGBA(red, green, blue, alpha) +} diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocation.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocation.kt new file mode 100644 index 0000000000000..878b78ab6eccd --- /dev/null +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocation.kt @@ -0,0 +1,36 @@ +// Copyright 2000-2026 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 org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.ImageResourceLocation +import org.jetbrains.icons.impl.rendering.DefaultImageModifiers +import org.jetbrains.icons.rendering.ImageModifiers +import org.jetbrains.jewel.foundation.InternalJewelApi + +// TODO: Replace with ModuleImageResourceLocation and custom Loader for it +@InternalJewelApi +@ApiStatus.Internal +public class PathImageResourceLocation(public val path: String, public val classLoader: ClassLoader?) : ImageResourceLocation { + public fun loadData(imageModifiers: ImageModifiers?): ByteArray { + val knownMods = imageModifiers as? DefaultImageModifiers + val finalPath = applyPathModifiers(path, knownMods) + val resourceStream = if (classLoader != null) { + classLoader.getResourceAsStream(finalPath) + } else ClassLoader.getSystemResourceAsStream(path) + return resourceStream?.readBytes() ?: error("Resource not found: $finalPath") + } + + private fun applyPathModifiers(path: String, modifiers: DefaultImageModifiers?): String { + if (modifiers == null) return path + return buildString { + append(path.substringBeforeLast('/', "")) + append('/') + append(path.substringBeforeLast('.').substringAfterLast('/')) + if (modifiers.isDark) { + append("_dark") + } + append('.') + append(path.substringAfterLast('.')) + } + } +} diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/RendererBasedIconPainter.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/RendererBasedIconPainter.kt new file mode 100644 index 0000000000000..53d0215460d91 --- /dev/null +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/RendererBasedIconPainter.kt @@ -0,0 +1,59 @@ +// Copyright 2000-2026 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.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.withSaveLayer +import androidx.compose.ui.platform.LocalDensity +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.icons.rendering.Dimensions +import org.jetbrains.icons.rendering.IconRenderer +import org.jetbrains.icons.rendering.ScalingContext +import org.jetbrains.jewel.foundation.InternalJewelApi + +@InternalJewelApi +@ApiStatus.Internal +public class RendererBasedIconPainter(private val iconRenderer: IconRenderer, private val scaling: ScalingContext) : Painter() { + override val intrinsicSize: Size = iconRenderer.calculateExpectedDimensions(scaling).toComposeSize() + + private var layerPaint: Paint? = null + private fun obtainPaint(): Paint { + var target = layerPaint + if (target == null) { + target = Paint() + layerPaint = target + } + return target + } + + override fun DrawScope.onDraw() { + val api = ComposePaintingApi(this, scaling = scaling) + val layerRect = Rect(Offset.Zero, Size(size.width, size.height)) + drawIntoCanvas { canvas -> + canvas.withSaveLayer(layerRect, obtainPaint()) { + iconRenderer.render(api) + } + } + } + + public companion object { + @Composable + @InternalJewelApi + @ApiStatus.Internal + public fun inferScalingContext(): ScalingContext { + return ComposeScalingContext( + LocalDensity.current.density + ) + } + } +} + +private fun Dimensions.toComposeSize(): Size { + return Size(width.toFloat(), height.toFloat()) +} diff --git a/platform/jewel/ui/src/main/resources/intellij.platform.jewel.ui.xml b/platform/jewel/ui/src/main/resources/intellij.platform.jewel.ui.xml index e08b82484c815..b8e93d174d476 100644 --- a/platform/jewel/ui/src/main/resources/intellij.platform.jewel.ui.xml +++ b/platform/jewel/ui/src/main/resources/intellij.platform.jewel.ui.xml @@ -1,7 +1,9 @@ - + + + diff --git a/platform/lang-impl/module-content.yaml b/platform/lang-impl/module-content.yaml index 042e844b3276d..2d863e30ca6d1 100644 --- a/platform/lang-impl/module-content.yaml +++ b/platform/lang-impl/module-content.yaml @@ -3,6 +3,8 @@ - name: intellij.platform.configurationStore.impl - name: intellij.platform.execution.impl - name: intellij.platform.foldings + - name: intellij.platform.icons.api.rendering + - name: intellij.platform.icons.impl - name: intellij.platform.ide.bootstrap - name: intellij.platform.ide.bootstrap.eel - name: intellij.platform.ide.bootstrap.kernel diff --git a/platform/platform-impl/bootstrap/BUILD.bazel b/platform/platform-impl/bootstrap/BUILD.bazel index 6cdd10ef73437..ecc235f81a228 100644 --- a/platform/platform-impl/bootstrap/BUILD.bazel +++ b/platform/platform-impl/bootstrap/BUILD.bazel @@ -46,6 +46,8 @@ jvm_library( "//platform/eel-impl", "//platform/platform-impl/initial-config-import", "//platform/eel-provider", + "//platform/icons-impl", + "//platform/icons-impl/intellij", ] ) @@ -92,6 +94,8 @@ jvm_library( "//platform/eel-impl:eel-impl_test_lib", "//platform/platform-impl/initial-config-import:initial-config-import_test_lib", "//platform/eel-provider:eel-provider_test_lib", + "//platform/icons-impl:icons-impl_test_lib", + "//platform/icons-impl/intellij:intellij_test_lib", ] ) ### auto-generated section `build intellij.platform.ide.bootstrap` end \ No newline at end of file diff --git a/platform/platform-impl/bootstrap/intellij.platform.ide.bootstrap.iml b/platform/platform-impl/bootstrap/intellij.platform.ide.bootstrap.iml index f86862f009fc2..59decad7ed82e 100644 --- a/platform/platform-impl/bootstrap/intellij.platform.ide.bootstrap.iml +++ b/platform/platform-impl/bootstrap/intellij.platform.ide.bootstrap.iml @@ -46,5 +46,7 @@ + + \ No newline at end of file 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 14d51c648b445..a1e5a7fb0ddc7 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 @@ -34,6 +34,11 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.annotations.VisibleForTesting +import org.jetbrains.icons.impl.intellij.IntelliJIconManager +import org.jetbrains.icons.impl.intellij.rendering.IntelliJIconRendererManager +import org.jetbrains.icons.impl.intellij.rendering.images.IntelliJImageResourceProvider +import org.jetbrains.icons.rendering.IconRendererManager +import org.jetbrains.icons.rendering.ImageResourceProvider import java.awt.Font import java.awt.GraphicsEnvironment import java.awt.Toolkit @@ -48,7 +53,11 @@ internal suspend fun initUi(initAwtToolkitJob: Job, isHeadless: Boolean, asyncSc // IdeaLaF uses AllIcons - icon manager must be activated if (!isHeadless) { span("icon manager activation") { - IconManager.activate(CoreIconManager()) + val iconManager = CoreIconManager() + IconManager.activate(iconManager) + } + span("new icon manager activation") { + IntelliJIconManager.activate() } } diff --git a/platform/platform-resources/generated/META-INF/intellij.moduleSets.core.ide.xml b/platform/platform-resources/generated/META-INF/intellij.moduleSets.core.ide.xml index 4c4131e9934e4..c3bb9d77bf8d2 100644 --- a/platform/platform-resources/generated/META-INF/intellij.moduleSets.core.ide.xml +++ b/platform/platform-resources/generated/META-INF/intellij.moduleSets.core.ide.xml @@ -119,6 +119,7 @@ + diff --git a/platform/platform-resources/generated/META-INF/intellij.moduleSets.core.lang.xml b/platform/platform-resources/generated/META-INF/intellij.moduleSets.core.lang.xml index 539f655e28e5a..b1428d6388910 100644 --- a/platform/platform-resources/generated/META-INF/intellij.moduleSets.core.lang.xml +++ b/platform/platform-resources/generated/META-INF/intellij.moduleSets.core.lang.xml @@ -120,6 +120,7 @@ + diff --git a/platform/platform-resources/generated/META-INF/intellij.moduleSets.essential.minimal.xml b/platform/platform-resources/generated/META-INF/intellij.moduleSets.essential.minimal.xml index 15ac00f3a8694..6cb7098139b16 100644 --- a/platform/platform-resources/generated/META-INF/intellij.moduleSets.essential.minimal.xml +++ b/platform/platform-resources/generated/META-INF/intellij.moduleSets.essential.minimal.xml @@ -121,6 +121,7 @@ + diff --git a/platform/platform-resources/generated/META-INF/intellij.moduleSets.essential.xml b/platform/platform-resources/generated/META-INF/intellij.moduleSets.essential.xml index 39e7a937950a1..a841c4ef439d4 100644 --- a/platform/platform-resources/generated/META-INF/intellij.moduleSets.essential.xml +++ b/platform/platform-resources/generated/META-INF/intellij.moduleSets.essential.xml @@ -122,6 +122,7 @@ + diff --git a/platform/platform-resources/generated/META-INF/intellij.moduleSets.ide.common.xml b/platform/platform-resources/generated/META-INF/intellij.moduleSets.ide.common.xml index 8fcdda615fbf0..66299b07654ff 100644 --- a/platform/platform-resources/generated/META-INF/intellij.moduleSets.ide.common.xml +++ b/platform/platform-resources/generated/META-INF/intellij.moduleSets.ide.common.xml @@ -125,6 +125,7 @@ + diff --git a/platform/util/BUILD.bazel b/platform/util/BUILD.bazel index ca4cd099714ca..3c4b1b6fb05d9 100644 --- a/platform/util/BUILD.bazel +++ b/platform/util/BUILD.bazel @@ -40,6 +40,8 @@ jvm_library( "//platform/util/coroutines", "//platform/util/multiplatform", "//platform/eel", + "//platform/icons-api", + "//platform/icons-api/rendering", ":platform_util_troveCompileOnly_provided", ], exports = [ @@ -47,6 +49,8 @@ jvm_library( "//platform/util-rt", "//platform/util/base", "//platform/util/multiplatform", + "//platform/icons-api", + "//platform/icons-api/rendering", ], runtime_deps = [ "//libraries/commons/io", @@ -69,6 +73,10 @@ jvm_library( "//platform/util/base:base_test_lib", "//platform/util/multiplatform", "//platform/util/multiplatform:multiplatform_test_lib", + "//platform/icons-api", + "//platform/icons-api:icons-api_test_lib", + "//platform/icons-api/rendering", + "//platform/icons-api/rendering:rendering_test_lib", ], runtime_deps = [ ":util", @@ -93,6 +101,8 @@ jvm_library( "//platform/util/coroutines:coroutines_test_lib", "//platform/util/multiplatform:multiplatform_test_lib", "//platform/eel:eel_test_lib", + "//platform/icons-api:icons-api_test_lib", + "//platform/icons-api/rendering:rendering_test_lib", ] ) ### auto-generated section `build intellij.platform.util` end diff --git a/platform/util/intellij.platform.util.iml b/platform/util/intellij.platform.util.iml index 29aa589b65012..7aab8e448e7b5 100644 --- a/platform/util/intellij.platform.util.iml +++ b/platform/util/intellij.platform.util.iml @@ -54,6 +54,8 @@ + + diff --git a/platform/util/ui/src/com/intellij/ui/icons/ImageDataByPathLoader.kt b/platform/util/ui/src/com/intellij/ui/icons/ImageDataByPathLoader.kt index f213baabc0588..4775423176536 100644 --- a/platform/util/ui/src/com/intellij/ui/icons/ImageDataByPathLoader.kt +++ b/platform/util/ui/src/com/intellij/ui/icons/ImageDataByPathLoader.kt @@ -14,6 +14,21 @@ import java.net.URL import java.util.function.Supplier import javax.swing.Icon +@Internal +fun findIconLoaderByPath(path: String, classLoader: ClassLoader): ImageDataLoader { + val originalPath = normalizePath(path) + val patched = patchIconPath(originalPath, classLoader) + val effectivePath = patched?.first ?: originalPath + val effectiveClassLoader = patched?.second ?: classLoader + + return ImageDataByPathLoader.createLoader(originalPath = originalPath, + originalClassLoader = effectiveClassLoader, + patched = patched, + path = effectivePath, + classLoader = effectiveClassLoader + ) +} + @Internal fun findIconByPath( @NonNls path: String, @@ -62,14 +77,28 @@ internal class ImageDataByPathLoader private constructor(override val path: Stri private val classLoader: ClassLoader, private val original: ImageDataByPathLoader?) : ImageDataLoader { companion object { + internal fun createLoader(originalPath: @NonNls String, + originalClassLoader: ClassLoader, + patched: Pair?, + path: String, + classLoader: ClassLoader): ImageDataByPathLoader { + val originalLoader = ImageDataByPathLoader(path = originalPath, classLoader = originalClassLoader, original = null) + return if (patched == null) originalLoader else ImageDataByPathLoader(path = path, classLoader = classLoader, original = originalLoader) + } + internal fun createIcon(originalPath: @NonNls String, originalClassLoader: ClassLoader, patched: Pair?, path: String, classLoader: ClassLoader, toolTip: Supplier? = null): CachedImageIcon { - val originalLoader = ImageDataByPathLoader(path = originalPath, classLoader = originalClassLoader, original = null) - val loader = if (patched == null) originalLoader else ImageDataByPathLoader(path = path, classLoader = classLoader, original = originalLoader) + val loader = createLoader( + originalPath, + originalClassLoader, + patched, + path, + classLoader + ) return CachedImageIcon(loader = loader, toolTip = toolTip, originalLoader = loader.original ?: loader) } diff --git a/plugins/devkit/intellij.devkit.compose/BUILD.bazel b/plugins/devkit/intellij.devkit.compose/BUILD.bazel index 4d4ffb192adeb..0de1a9024cd55 100644 --- a/plugins/devkit/intellij.devkit.compose/BUILD.bazel +++ b/plugins/devkit/intellij.devkit.compose/BUILD.bazel @@ -63,6 +63,10 @@ jvm_library( "//platform/util/jdom", "//platform/external-system-impl:externalSystem-impl", "//plugins/gradle", + "//platform/platform-impl/rpc", + "//fleet/util/core", + "//libraries/kotlinx/serialization/json", + "//libraries/kotlinx/serialization/core", ], plugins = ["@lib//:compose-plugin"] ) @@ -112,6 +116,10 @@ jvm_library( "//platform/util/jdom:jdom_test_lib", "//platform/external-system-impl:externalSystem-impl_test_lib", "//plugins/gradle:gradle_test_lib", + "//platform/platform-impl/rpc:rpc_test_lib", + "//fleet/util/core:core_test_lib", + "//libraries/kotlinx/serialization/json:json_test_lib", + "//libraries/kotlinx/serialization/core:core_test_lib", ], plugins = ["@lib//:compose-plugin"] ) diff --git a/plugins/devkit/intellij.devkit.compose/intellij.devkit.compose.iml b/plugins/devkit/intellij.devkit.compose/intellij.devkit.compose.iml index 6d956224c0c3f..61f8a6fd1f7f1 100644 --- a/plugins/devkit/intellij.devkit.compose/intellij.devkit.compose.iml +++ b/plugins/devkit/intellij.devkit.compose/intellij.devkit.compose.iml @@ -68,5 +68,9 @@ + + + + \ 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 effb48ba9ca46..0c5fe5e1a4952 100644 --- a/plugins/devkit/intellij.devkit.compose/src/demo/ComponentShowcaseTab.kt +++ b/plugins/devkit/intellij.devkit.compose/src/demo/ComponentShowcaseTab.kt @@ -38,10 +38,14 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onFirstVisible 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.Icon import org.jetbrains.jewel.bridge.toComposeColor import org.jetbrains.jewel.foundation.LocalComponent import org.jetbrains.jewel.foundation.actionSystem.provideData @@ -457,7 +461,7 @@ private fun IconsShowcase() { } IconButton(onClick = {}, Modifier.size(24.dp)) { - Icon(key = AllIconsKeys.Actions.Close, contentDescription = "Close") + Icon(AllIconsKeys.Actions.Close, "Close") } IconActionButton( @@ -468,9 +472,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 ac2735366f0b6..b02e89a1f5b43 100644 --- a/plugins/devkit/intellij.devkit.compose/src/showcase/ComposePerformanceDemoAction.kt +++ b/plugins/devkit/intellij.devkit.compose/src/showcase/ComposePerformanceDemoAction.kt @@ -1,6 +1,7 @@ // 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.devkit.compose.showcase +import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -8,7 +9,9 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween 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 @@ -18,8 +21,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.withFrameNanos +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.setValue 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 @@ -31,18 +37,27 @@ 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.icons.modifiers.IconModifier +import org.jetbrains.icons.modifiers.fillMaxSize +import org.jetbrains.icons.swing.toNewIcon 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.LinkedList import javax.swing.BoxLayout import javax.swing.ButtonGroup +import javax.swing.Icon import javax.swing.JComponent import javax.swing.JLabel import javax.swing.JPanel @@ -69,7 +84,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 @@ -94,17 +109,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)) @@ -139,6 +159,7 @@ private class MyDialog(project: Project?, dialogTitle: String) : val comp = when (testCase) { TestCase.TextAnimation -> createTextAnimationComponent() TestCase.Canvas -> createClockComponent() + TestCase.Icons -> createIconsComponent() } centerPanelWrapper.add(comp) @@ -149,6 +170,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 Icon).toNewIcon(IconModifier.fillMaxSize()) }) + } + 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..414d7743f93b5 100644 --- a/plugins/devkit/intellij.devkit.compose/src/showcase/ComposeShowcase.kt +++ b/plugins/devkit/intellij.devkit.compose/src/showcase/ComposeShowcase.kt @@ -50,6 +50,7 @@ import com.intellij.icons.AllIcons import com.intellij.openapi.fileChooser.FileChooser import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.keymap.KeymapUtil +import com.intellij.openapi.project.Project import com.intellij.ui.UIBundle import com.intellij.util.ui.JBUI import kotlinx.coroutines.Dispatchers.IO @@ -84,8 +85,9 @@ import java.awt.event.KeyEvent import java.io.File import javax.swing.KeyStroke + @Composable -internal fun ComposeShowcase() { +internal fun ComposeShowcase(project: Project) { Column( verticalArrangement = Arrangement.spacedBy(15.dp), modifier = Modifier.padding(10.dp) @@ -110,6 +112,7 @@ internal fun ComposeShowcase() { TextFieldWithButton() TooltipAreaSimple() InfiniteAnimation() + Icons(project) } } } diff --git a/plugins/devkit/intellij.devkit.compose/src/showcase/ComposeShowcaseAction.kt b/plugins/devkit/intellij.devkit.compose/src/showcase/ComposeShowcaseAction.kt index a92106ffecf83..fa35f0c62e1ae 100644 --- a/plugins/devkit/intellij.devkit.compose/src/showcase/ComposeShowcaseAction.kt +++ b/plugins/devkit/intellij.devkit.compose/src/showcase/ComposeShowcaseAction.kt @@ -13,9 +13,9 @@ import org.jetbrains.jewel.bridge.compose import java.awt.Dimension import javax.swing.JComponent -private fun createComposeShowcaseComponent(): JComponent { +private fun createComposeShowcaseComponent(project: Project): JComponent { return compose { - ComposeShowcase() + ComposeShowcase(project) } } @@ -23,7 +23,7 @@ internal class ComposeShowcaseAction : DumbAwareAction() { override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { - e.presentation.isEnabledAndVisible = e.project != null && PsiUtil.isPluginProject(e.project!!) + e.presentation.isEnabledAndVisible = e.project != null // && PsiUtil.isPluginProject(e.project!!) } override fun actionPerformed(e: AnActionEvent) { @@ -31,7 +31,7 @@ internal class ComposeShowcaseAction : DumbAwareAction() { } } -private class ComposeShowcaseDialog(project: Project?, @NlsSafe dialogTitle: String) : +private class ComposeShowcaseDialog(val project: Project?, @NlsSafe dialogTitle: String) : DialogWrapper(project, null, true, IdeModalityType.MODELESS, false) { init { @@ -40,7 +40,7 @@ private class ComposeShowcaseDialog(project: Project?, @NlsSafe dialogTitle: Str } override fun createCenterPanel(): JComponent { - return Wrapper(createComposeShowcaseComponent()).apply { + return Wrapper(createComposeShowcaseComponent(project!!)).apply { minimumSize = Dimension(200, 100) preferredSize = Dimension(800, 600) } diff --git a/plugins/devkit/intellij.devkit.compose/src/showcase/Icons.kt b/plugins/devkit/intellij.devkit.compose/src/showcase/Icons.kt new file mode 100644 index 0000000000000..3b35eb9bf9118 --- /dev/null +++ b/plugins/devkit/intellij.devkit.compose/src/showcase/Icons.kt @@ -0,0 +1,257 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.devkit.compose.showcase + +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.guessProjectDir +import com.intellij.openapi.vfs.findFile +import com.intellij.ui.BadgeDotProvider +import com.intellij.ui.BadgeIcon +import com.intellij.ui.SpinningProgressIcon +import com.intellij.util.IconUtil +import kotlinx.coroutines.delay +import kotlinx.serialization.json.Json +import org.jetbrains.icons.Icon +import org.jetbrains.icons.IconManager +import org.jetbrains.icons.deferredIcon +import org.jetbrains.icons.design.Circle +import org.jetbrains.icons.design.IconAlign +import org.jetbrains.icons.design.percent +import org.jetbrains.icons.icon +import org.jetbrains.icons.swing.swingIcon +import org.jetbrains.icons.swing.toNewIcon +import org.jetbrains.icons.swing.toSwingIcon +import org.jetbrains.icons.modifiers.IconModifier +import org.jetbrains.icons.modifiers.align +import org.jetbrains.icons.modifiers.fillMaxSize +import org.jetbrains.icons.modifiers.patchSvg +import org.jetbrains.icons.modifiers.size +import org.jetbrains.jewel.bridge.toAwtColor +import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icon.badge +import org.jetbrains.jewel.ui.icon.tintColor +import javax.swing.BoxLayout +import javax.swing.JLabel +import javax.swing.JPanel + +@OptIn(ExperimentalJewelApi::class) +@Composable +internal fun Icons(project: Project) { + val icons = mutableListOf() + val missingIcon = remember { AllIcons.General.Error.toNewIcon(modifier = IconModifier.fillMaxSize()) } + + val transition = rememberInfiniteTransition() + val iconSize by transition.animateFloat( + 30f, + 80f, + infiniteRepeatable(tween(durationMillis = 1000, easing = EaseInOut), repeatMode = RepeatMode.Reverse), + ) + + Box(Modifier.size(80.dp)) { + Icon(icon { + swingIcon(AllIcons.Actions.NewFolder, modifier = IconModifier.fillMaxSize()) + }, "New Folder", modifier = Modifier.size(iconSize.dp)) + } + + val duration = 50L + val animatedIcon = remember { + icon { + animation { + frame(duration) { + swingIcon(AllIcons.Process.Step_1) + } + frame(duration) { + swingIcon(AllIcons.Process.Step_2) + } + frame(duration) { + swingIcon(AllIcons.Process.Step_3) + } + frame(duration) { + swingIcon(AllIcons.Process.Step_4) + } + frame(duration) { + swingIcon(AllIcons.Process.Step_5) + } + frame(duration) { + swingIcon(AllIcons.Process.Step_6) + } + frame(duration) { + swingIcon(AllIcons.Process.Step_7) + } + frame(duration) { + swingIcon(AllIcons.Process.Step_8) + } + } + } + } + + icons.add(remember { + ShowcaseIcon( + icon { + icon(missingIcon) + badge(Color.Green, Circle) + }, + BadgeIcon(AllIcons.General.Error, Color.Green.toAwtColor(), BadgeDotProvider()), + "Icon with Badge" + ) + }) + + val size = 30.percent + icons.add(remember { + ShowcaseIcon( + icon { + column(spacing = 5.percent, modifier = IconModifier.fillMaxSize()) { + row(spacing = 5.percent) { + icon(missingIcon, modifier = IconModifier.tintColor(Color.Yellow, BlendMode.Color)) + icon(missingIcon, modifier = IconModifier.tintColor(Color.Green, BlendMode.Hue)) + } + row(spacing = 5.percent) { + icon(missingIcon, modifier = IconModifier.tintColor(Color.Blue, BlendMode.Saturation)) + icon(missingIcon, modifier = IconModifier.tintColor(Color.Cyan, BlendMode.Multiply)) + } + } + icon(animatedIcon, modifier = IconModifier.size(size).align(IconAlign.TopLeft)) + icon(animatedIcon, modifier = IconModifier.size(size).align(IconAlign.TopCenter)) + icon(animatedIcon, modifier = IconModifier.size(size).align(IconAlign.TopRight)) + icon(animatedIcon, modifier = IconModifier.size(size).align(IconAlign.CenterLeft)) + icon(animatedIcon, modifier = IconModifier.size(size).tintColor(Color.Red).align(IconAlign.Center)) + icon(animatedIcon, modifier = IconModifier.size(size).align(IconAlign.CenterRight)) + icon(animatedIcon, modifier = IconModifier.size(size).align(IconAlign.BottomLeft)) + icon(animatedIcon, modifier = IconModifier.size(size).align(IconAlign.BottomCenter)) + icon(animatedIcon, modifier = IconModifier.size(size).align(IconAlign.BottomRight)) + }, + null, + "Complex Layout Icon" + ) + }) + + val deferredIcon = remember { + project.guessProjectDir()?.findFile("build.gradle.kts")?.let { + IconUtil.getIcon(it, 0, project) + } + } ?: AllIcons.General.Error + + icons.add( + ShowcaseIcon( + deferredIcon.toNewIcon(), + deferredIcon, + "Legacy Deferred Icon" + ) + ) + + icons.add(remember { + ShowcaseIcon( + animatedIcon, + SpinningProgressIcon(), + "Animated Icon" + ) + }) + + val json = Json { serializersModule = IconManager.getInstance().getSerializersModule() } + val dynIcon = deferredIcon(missingIcon) { + delay(5000) + icon { + icon(missingIcon, modifier = IconModifier.patchSvg { + replaceUnlessMatches("fill", "white", "green") + }) + } + } + val serialized = json.encodeToString(dynIcon) + val deserialized = json.decodeFromString(serialized) + + icons.add(remember { + ShowcaseIcon( + dynIcon, + null, + "Deferred Icon" + ) + }) + + icons.add(remember { + ShowcaseIcon( + deserialized, + null, + "Deserialized Deferred Icon" + ) + }) + + Column(Modifier.fillMaxWidth()) { + // Header + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Text("Title", Modifier.weight(1f)) + Text("Compose", Modifier.weight(1f)) + Text("Swing", Modifier.weight(1f)) + Text("Old Api - Swing", Modifier.weight(1f)) + } + + // Body + Column(Modifier.fillMaxWidth()) { + for (icon in icons) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(icon.title, Modifier.weight(1f)) + Column(Modifier.weight(1f)) { + Icon(icon.icon, "Icon") + } + wrapStaticSwingIcon( + icon.icon.toSwingIcon(), + modifier = Modifier.weight(1f) + ) + wrapStaticSwingIcon( + icon.swingAlternative ?: AllIcons.General.Warning, + modifier = Modifier.weight(1f) + ) + } + } + } + } +} + +class ShowcaseIcon( + val icon: Icon, + val swingAlternative: javax.swing.Icon?, + val title: String +) + +@Composable +private fun wrapStaticSwingIcon(icon: javax.swing.Icon, modifier: Modifier = Modifier) { + SwingPanel( + factory = { + JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add( + JLabel( + icon + ).apply { + border = null + isOpaque = false + } + ) + } + }, + background = Color.Transparent, + modifier = modifier.fillMaxHeight(), + ) +} \ No newline at end of file