diff --git a/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/Convertors.kt b/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/Convertors.kt index e26b57384188..de5eef80ced5 100644 --- a/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/Convertors.kt +++ b/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/Convertors.kt @@ -6,11 +6,16 @@ import com.intellij.util.SVGLoader import com.intellij.platform.icons.design.BlendMode import com.intellij.platform.icons.filters.ColorFilter import com.intellij.platform.icons.impl.filters.TintColorFilter +import com.intellij.platform.icons.impl.patchers.AUTHORED_STROKE_VARIANT_SUFFIX import com.intellij.platform.icons.impl.patchers.DefaultSvgPatcher import com.intellij.platform.icons.impl.patchers.SvgPatchOperation +import com.intellij.platform.icons.impl.patchers.authoredStrokeSvgPatcher +import com.intellij.platform.icons.impl.patchers.strokeSvgPatcher +import com.intellij.platform.icons.impl.patchers.writeSvgAttribute import com.intellij.platform.icons.swing.toAwtColor import java.awt.Color import java.awt.image.RGBImageFilter +import com.intellij.platform.icons.design.Color as DesignColor internal fun ColorFilter.toAwtFilter(): RGBImageFilter { return when (this) { @@ -21,45 +26,76 @@ internal fun ColorFilter.toAwtFilter(): RGBImageFilter { } } -internal fun DefaultSvgPatcher.toIJPatcher(rootPatcher: SVGLoader.SvgElementColorPatcherProvider?): SVGLoader.SvgElementColorPatcherProvider { - return ProxySvgPatcher(this, rootPatcher) +/** + * The color patcher this frontend hands to the IntelliJ SVG loader for an icon stroked in [stroke] and patched by + * [patcher], on top of whatever [rootPatcher] already patches. + * + * Returns `null` when there is nothing of our own to patch, which is what tells the loader to take its plain path. + */ +internal fun toIJPatcher( + stroke: DesignColor?, + patcher: DefaultSvgPatcher?, + rootPatcher: SVGLoader.SvgElementColorPatcherProvider?, +): SVGLoader.SvgElementColorPatcherProvider? { + if (stroke == null && patcher == null) return null + return ProxySvgPatcher(stroke = stroke, patcher = patcher, rootPatcher = rootPatcher) } private class ProxySvgPatcher( - private val patcher: DefaultSvgPatcher, + private val stroke: DesignColor?, + private val patcher: DefaultSvgPatcher?, private val rootPatcher: SVGLoader.SvgElementColorPatcherProvider? = null -): SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher { - override fun attributeForPath(path: String): SvgAttributePatcher = ProxySvgAttributePatcher(patcher, rootPatcher?.attributeForPath(path)) +): SVGLoader.SvgElementColorPatcherProvider { + // Which stroke patch applies depends on the file the loader ends up resolving, so it can only be picked per path: a + // hand-authored stroke variant is recolored as it is, while a base icon is reduced to an outline. + override fun attributeForPath(path: String): SvgAttributePatcher { + val strokePatcher = stroke?.let { + if (path.isAuthoredStrokeVariant()) authoredStrokeSvgPatcher(it) else strokeSvgPatcher(it) + } + // The icon's own patcher runs first and the stroke substitution after it, so an icon that explicitly recolors a + // palette color keeps that color: the stroke operation no longer matches what the explicit one already replaced. + val combined = patcher?.combineWith(strokePatcher) ?: strokePatcher + return ProxySvgAttributePatcher(combined as? DefaultSvgPatcher, rootPatcher?.attributeForPath(path)) + } + override fun digest(): LongArray { + val own = longArrayOf(patcher.hashCode().toLong(), stroke.hashCode().toLong()) if (rootPatcher != null) { - return rootPatcher.digest() + longArrayOf(patcher.hashCode().toLong()) + return rootPatcher.digest() + own } else { - return longArrayOf(patcher.hashCode().toLong()) + return own } } } +private fun String.isAuthoredStrokeVariant(): Boolean = + substringBeforeLast('.', missingDelimiterValue = "").endsWith(AUTHORED_STROKE_VARIANT_SUFFIX) + private class ProxySvgAttributePatcher( - private val patcher: DefaultSvgPatcher, + private val patcher: DefaultSvgPatcher?, private val rootPatcher: SvgAttributePatcher? = null ): SvgAttributePatcher { override fun patchColors(attributes: MutableMap) { rootPatcher?.patchColors(attributes) // TODO Support filtered operations - not possible with current IJ svg loader - for (operation in patcher.operations) { + val write = { name: String, value: String -> + writeSvgAttribute(name, value, { n, v -> attributes[n] = v }, { attributes.remove(it) }) + } + for (operation in patcher?.operations ?: return) { when (operation.operation) { SvgPatchOperation.Operation.Add -> { if (!attributes.containsKey(operation.attributeName)) { - attributes[operation.attributeName] = operation.value!! + write(operation.attributeName, operation.value!!) } } SvgPatchOperation.Operation.Replace -> { - if (operation.conditional) { - if (operation.matches(attributes[operation.attributeName]) != operation.negatedCondition) { - attributes.replace(operation.attributeName, operation.value!!) - } - } else { - attributes.replace(operation.attributeName, operation.value!!) + // Replace never creates an attribute, conditionally or not: an element that does not carry the attribute + // inherits it, and adding one here would override that inheritance. Add exists for that. + if (attributes.containsKey(operation.attributeName) && + (!operation.conditional || + operation.matches(attributes[operation.attributeName]) != operation.negatedCondition) + ) { + write(operation.attributeName, operation.value!!) } } SvgPatchOperation.Operation.Remove -> { @@ -71,7 +107,7 @@ private class ProxySvgAttributePatcher( attributes.remove(operation.attributeName) } } - SvgPatchOperation.Operation.Set -> attributes[operation.attributeName] = operation.value!! + SvgPatchOperation.Operation.Set -> write(operation.attributeName, operation.value!!) } } } @@ -93,7 +129,7 @@ private class AwtColorFilter(color: Color, val keepGray: Boolean, val keepBright } companion object { - fun fromColorAndBlendMode(color: com.intellij.platform.icons.design.Color, blendMode: BlendMode): AwtColorFilter { + fun fromColorAndBlendMode(color: DesignColor, blendMode: BlendMode): AwtColorFilter { var keepGray = true var keepBrightness = true when (blendMode) { diff --git a/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/images/SwingImageResourceHolder.kt b/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/images/SwingImageResourceHolder.kt index 951d75fe80aa..69b6b71c7280 100644 --- a/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/images/SwingImageResourceHolder.kt +++ b/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/images/SwingImageResourceHolder.kt @@ -7,7 +7,6 @@ import com.intellij.ui.scale.ScaleContext import com.intellij.platform.icons.impl.intellij.rendering.toAwtFilter import com.intellij.platform.icons.impl.intellij.rendering.toIJPatcher import com.intellij.platform.icons.impl.patchers.DefaultSvgPatcher -import com.intellij.platform.icons.impl.patchers.strokeSvgPatcher import com.intellij.platform.icons.impl.rendering.DefaultImageModifiers import com.intellij.platform.icons.rendering.Dimensions import com.intellij.platform.icons.rendering.ImageModifiers @@ -26,15 +25,16 @@ internal fun ImageModifiers?.toLoadParameters(): LoadIconParameters { filters.add(colorFilter.toAwtFilter()) } val knownModifiers = this as? DefaultImageModifiers - val strokePatcher = knownModifiers?.stroke?.let { strokeSvgPatcher(it) } val ijModifiers = this as? IntelliJImageModifiers - // The icon's own patcher runs first and the stroke substitution after it, so an icon that explicitly recolors a - // palette color keeps that color: the stroke operation no longer matches what the explicit one already replaced. - // The elvis is what keeps a stroke-only icon patched at all — `svgPatcher` is null whenever an icon carries no - // explicit patcher, and combining outwards from null would discard the stroke patcher entirely. - val combinedPatcher = this?.svgPatcher?.combineWith(strokePatcher) ?: strokePatcher - val colorPatcher = (combinedPatcher as? DefaultSvgPatcher)?.toIJPatcher(ijModifiers?.legacyPatcherProvider) - val isStroke = knownModifiers?.stroke != null + val stroke = knownModifiers?.stroke + // The stroke patch is combined per resolved path rather than here, because which patch an icon takes depends on + // whether the loader ends up resolving a hand-authored stroke variant for it. + val colorPatcher = toIJPatcher( + stroke = stroke, + patcher = this?.svgPatcher as? DefaultSvgPatcher, + rootPatcher = ijModifiers?.legacyPatcherProvider, + ) + val isStroke = stroke != null return LoadIconParameters( filters = filters, // A stroked icon loads its light artwork even in a dark theme. The palette describes the light variants, so diff --git a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokePatcherTest.kt b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokePatcherTest.kt index 0612262026b7..8f6522c486be 100644 --- a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokePatcherTest.kt +++ b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokePatcherTest.kt @@ -1,7 +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 com.intellij.platform.icons.impl.intellij +import com.intellij.platform.icons.design.Color import com.intellij.platform.icons.impl.design.DefaultSRGB +import com.intellij.platform.icons.impl.patchers.authoredStrokeSvgPatcher import com.intellij.platform.icons.impl.patchers.DefaultSvgPatcher import com.intellij.platform.icons.impl.patchers.SvgPatchOperation import com.intellij.platform.icons.impl.patchers.strokeSvgPatcher @@ -75,6 +77,43 @@ class StrokePatcherTest { assert(unconditional.isEmpty()) { "unconditional operations would erase off-palette artwork: $unconditional" } } + @Test + fun `stroking a hand-authored variant recolors the white it is drawn in, on both attributes`() { + for (value in AUTHORED_STROKE_COLORS) { + for (attribute in ATTRIBUTES) { + val match = conditionalReplacement(attribute, value, operations(authored = true)) + assertNotNull(match) { "no $attribute replacement for $value in a hand-authored stroke variant" } + assert(match.value == red.toHex()) { "$attribute for $value became ${match.value}, wanted red" } + } + } + } + + @Test + fun `stroking a hand-authored variant reduces no palette color`() { + // The variant is already an outline; dropping a background tint out of it could only take away artwork, since a + // tint that reads as a background in a filled icon can be the outline itself in a drawing made of outlines. + val expected = AUTHORED_STROKE_COLORS.size * ATTRIBUTES.size + val actual = operations(authored = true).size + assert(actual == expected) { + "expected $expected operations to recolor a hand-authored variant, got $actual: ${operations(authored = true)}" + } + } + + @Test + fun `stroking a hand-authored variant in opaque white patches nothing`() { + // The variant is already drawn in white, so it is left exactly as authored — including any opacity it carries, + // which recoloring white to white would still normalize away. + val operations = operations(authored = true, stroke = DefaultSRGB.fromHex("#FFFFFFFF")) + assert(operations.isEmpty()) { "stroking white must leave a hand-authored variant untouched, got $operations" } + } + + @Test + fun `stroking a hand-authored variant in translucent white still recolors`() { + // Translucent white is a different color from the white the variant is drawn in, so it is a real substitution. + val operations = operations(authored = true, stroke = DefaultSRGB.fromHex("#FFFFFF80")) + assert(operations.isNotEmpty()) { "translucent white is not the color the variant is authored in" } + } + private fun assertRecolored(value: String, what: String) { for (attribute in ATTRIBUTES) { val match = conditionalReplacement(attribute, value) @@ -83,16 +122,20 @@ class StrokePatcherTest { } } - private fun conditionalReplacement(attribute: String, expectedValue: String): SvgPatchOperation? = - operations().singleOrNull { + private fun conditionalReplacement( + attribute: String, + expectedValue: String, + operations: List = operations(), + ): SvgPatchOperation? = + operations.singleOrNull { it.attributeName == attribute && it.operation == SvgPatchOperation.Operation.Replace && it.conditional && it.expectedValue == expectedValue } - private fun operations(): List { - val patcher = strokeSvgPatcher(red) as? DefaultSvgPatcher + private fun operations(authored: Boolean = false, stroke: Color = red): List { + val patcher = (if (authored) authoredStrokeSvgPatcher(stroke) else strokeSvgPatcher(stroke)) as? DefaultSvgPatcher assertNotNull(patcher) return patcher.operations } @@ -113,5 +156,8 @@ class StrokePatcherTest { listOf( "#ebecf0", "#e7effd", "#dff2e0", "#f2fcf3", "#ffe8e8", "#fff5f5", "#fff8e3", "#fff4eb", "#eee0ff", ) + + // A hand-authored stroke variant is drawn in white alone, in either spelling an SVG may use for it. + private val AUTHORED_STROKE_COLORS = listOf("white", "#ffffff") } } diff --git a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokeRenderTest.kt b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokeRenderTest.kt index 91e382ff6a07..0b45bb818b9f 100644 --- a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokeRenderTest.kt +++ b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokeRenderTest.kt @@ -10,6 +10,7 @@ import com.intellij.platform.icons.patchers.svgPatcher import com.intellij.platform.icons.swing.toSwingIcon import com.intellij.testFramework.junit5.TestApplication import java.awt.image.BufferedImage +import kotlin.math.roundToInt import javax.swing.UIManager import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue @@ -98,6 +99,39 @@ class StrokeRenderTest { assertEquals(0, counts.red, "$path: the stroke color overrode the icon's own patcher, got $counts") } + @Test + fun `icon with a hand-authored stroke variant strokes red`() { + // run.svg is a palette background fill inside an off-palette green outline, so reducing it to an outline cannot + // produce the stroked glyph — the icon ships run_stroke.svg, a separate drawing, for exactly that. + val stroked = count(render("expui/run/run.svg", IconModifier.stroke(red))) + println("[stroke-render] expui/run/run.svg stroked=[$stroked]") + + assertTrue(stroked.red > 0) { "expected stroking to produce red pixels, got $stroked" } + assertEquals(0, stroked.green, "the authored green outline must not survive stroking, got $stroked") + } + + @Test + fun `stroking replaces the opacity a shape was authored with`() { + // cursorText.svg draws one of its shapes at fill-opacity="0.7". That opacity described the color stroking has just + // replaced, so it cannot go on shading the color that replaced it. + val stroked = render("expui/windows/mouse/cursorText.svg", IconModifier.stroke(red)) + val authored = (0.7f * 255).roundToInt() + val atAuthoredOpacity = (authored - 1..authored + 1).sumOf { alpha -> countAtAlpha(stroked, alpha) } + println("[stroke-render] cursorText.svg stroked=[${count(stroked)}] atAuthoredOpacity=$atAuthoredOpacity") + + assertEquals(0, atAuthoredOpacity, "pixels are still rendered at the authored 0.7 opacity") + } + + private fun countAtAlpha(image: BufferedImage, alpha: Int): Int { + var count = 0 + for (y in 0 until image.height) { + for (x in 0 until image.width) { + if (image.getRGB(x, y) ushr 24 and 0xFF == alpha) count++ + } + } + return count + } + private fun assertStrokedRed(path: String) { val plain = count(render(path, IconModifier)) val stroked = count(render(path, IconModifier.stroke(red))) diff --git a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgAttributeWriterTest.kt b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgAttributeWriterTest.kt new file mode 100644 index 000000000000..7712088b5103 --- /dev/null +++ b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgAttributeWriterTest.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 com.intellij.platform.icons.impl.intellij + +import com.intellij.platform.icons.impl.patchers.writeSvgAttribute +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * Pins how a patch writes a color, which every frontend does through this one function so that the same operation + * produces the same document everywhere. + * + * A color attribute carries no alpha channel — alpha lives on the paired opacity attribute — so writing a color has to + * land across both, and the opacity the document was authored with described the color that was just replaced. + */ +class SvgAttributeWriterTest { + @Test + fun `a translucent color is split across the color attribute and its opacity`() { + // #ff000080 is not a value `fill` can hold: the attribute has no alpha channel. + val result = write("fill", "#ff000080") + + assertEquals("#ff0000", result["fill"]) + assertEquals(0x80 / 255f, result["fill-opacity"]?.toFloat(), "the alpha belongs on fill-opacity") + } + + @Test + fun `an opaque color clears the opacity the document was authored with`() { + val result = write("fill", "#ff0000", mapOf("fill-opacity" to "0.7")) + + assertEquals("#ff0000", result["fill"]) + assertNull(result["fill-opacity"], "0.7 described the color that was just replaced, not the new one") + } + + @Test + fun `every color attribute with a paired opacity carries its own`() { + for ((attribute, opacity) in PAIRS) { + val result = write(attribute, "#ff000080", mapOf(opacity to "0.7")) + assertEquals("#ff0000", result[attribute]) + assertEquals(0x80 / 255f, result[opacity]?.toFloat()) { "$attribute did not write $opacity" } + } + } + + @Test + fun `a value that is not a color leaves the opacity alone`() { + val result = write("fill", "transparent", mapOf("fill-opacity" to "0.7")) + + assertEquals("transparent", result["fill"]) + assertEquals("0.7", result["fill-opacity"], "only a color write says anything about opacity") + } + + @Test + fun `an attribute that holds no color is written verbatim`() { + assertEquals("#ff000080", write("stroke-width", "#ff000080")["stroke-width"]) + } + + private fun write( + attributeName: String, + value: String, + attributes: Map = emptyMap(), + ): Map { + val result = attributes.toMutableMap() + writeSvgAttribute(attributeName, value, { name, written -> result[name] = written }, { result.remove(it) }) + return result + } + + private companion object { + private val PAIRS = + listOf( + "fill" to "fill-opacity", + "stroke" to "stroke-opacity", + "stop-color" to "stop-opacity", + "flood-color" to "flood-opacity", + ) + } +} diff --git a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgPatchOperationMatchesTest.kt b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgPatchOperationMatchesTest.kt index 0677745c1edf..d4e29732ec92 100644 --- a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgPatchOperationMatchesTest.kt +++ b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgPatchOperationMatchesTest.kt @@ -40,10 +40,36 @@ class SvgPatchOperationMatchesTest { } } + @Test + fun `hex shorthands match the form they stand for`() { + // Every digit stands for a doubled pair, so #fff names the same color as #ffffff. + assertTrue(condition("fill", "#ffffff").matches("#fff")) + assertTrue(condition("fill", "#fff").matches("#FFFFFF")) + assertTrue(condition("fill", "#66cc77").matches("#6c7")) + } + + @Test + fun `an alpha the literal carries is not part of which color it names`() { + // A substitution replaces the opacity of what it matched along with the color, so the authored alpha does not + // decide whether the color is a palette color. + assertTrue(condition("fill", "#6c707e").matches("#6c707eff")) + assertTrue(condition("fill", "#6c707e").matches("#6C707E80")) + assertTrue(condition("fill", "#ffffff").matches("#fff8")) + } + + @Test + fun `a hex literal of no valid length names no color`() { + // There is no five- or seven-digit hex color; such a value is not a color and must not be expanded into one. + assertFalse(condition("fill", "#6c707e").matches("#6c707")) + assertFalse(condition("fill", "#ffffff").matches("#fffff")) + assertFalse(condition("fill", "#ffffff").matches("#ff")) + } + @Test fun `different colors do not match`() { assertFalse(condition("fill", "#6c707e").matches("#818594")) assertFalse(condition("fill", "white").matches("black")) + assertFalse(condition("fill", "#ffffff").matches("#fffffe")) } @Test diff --git a/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/StrokePatcher.kt b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/StrokePatcher.kt index dd045b4d49cc..f2f9cfffc784 100644 --- a/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/StrokePatcher.kt +++ b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/StrokePatcher.kt @@ -38,6 +38,44 @@ fun strokeSvgPatcher(stroke: Color): SvgPatcher = svgPatcher { } } +/** + * The SVG patch for an icon that ships a hand-authored stroke variant, rendering it in [stroke]. + * + * Such a variant is a separate drawing of the same glyph, already reduced to an outline by hand and authored in white: + * it needs recoloring, not the palette reduction [strokeSvgPatcher] performs. Reducing it a second time could only take + * away artwork the author put there deliberately, since a tint that reads as a background in a filled icon can be the + * outline itself in a drawing made of outlines. + * + * Rendering one in opaque white patches nothing at all: the drawing is already that color, so it is left exactly as + * authored, down to any opacity it carries. Recoloring it white instead would be a substitution of a color by itself + * that still normalizes that opacity away. + */ +@ApiStatus.Internal +fun authoredStrokeSvgPatcher(stroke: Color): SvgPatcher = svgPatcher { + val target = stroke.toHex() + if (target.equals(OPAQUE_WHITE, ignoreCase = true)) return@svgPatcher + + for (color in authoredStrokePalette) { + replaceIfMatches("fill", color, target) + replaceIfMatches("stroke", color, target) + } +} + +/** + * The file-name suffix marking a hand-authored stroke variant, as in `run.svg` and its `run_stroke.svg`. + * + * Every frontend resolves the variant by this same suffix, so that stroking an icon reaches the same file whichever one + * renders it. + */ +@ApiStatus.Internal +const val AUTHORED_STROKE_VARIANT_SUFFIX: String = "_stroke" + +/** The only color a hand-authored stroke variant is drawn in, in both spellings an SVG may use for it. */ +private val authoredStrokePalette: List = listOf("white", "#ffffff") + +/** The hex [Color.toHex] produces for fully opaque white; a translucent white carries an alpha component instead. */ +private const val OPAQUE_WHITE: String = "#ffffff" + /** * Foreground palette entries an SVG may spell as a color keyword rather than as hex. * diff --git a/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgAttributeWriter.kt b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgAttributeWriter.kt new file mode 100644 index 000000000000..c69e501d0ed2 --- /dev/null +++ b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgAttributeWriter.kt @@ -0,0 +1,70 @@ +// 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.platform.icons.impl.patchers + +import org.jetbrains.annotations.ApiStatus + +/** + * Writes [value] into [attributeName], keeping a color attribute and its paired opacity attribute consistent. + * + * A color attribute carries no alpha channel: alpha belongs on `fill-opacity` and its siblings. A color written into + * one is therefore split across the pair, and the opacity the document was authored with gives way to the one being + * written — it described the color that has just been replaced, not the new one. + * + * Writing lives here rather than in each renderer for the same reason condition evaluation lives on the operation: a + * frontend that split the pair differently would draw the same icon differently. + */ +@ApiStatus.Internal +fun writeSvgAttribute( + attributeName: String, + value: String, + setAttribute: (String, String) -> Unit, + removeAttribute: (String) -> Unit, +) { + val opacityAttributeName = OPACITY_ATTRIBUTES[attributeName] + val color = value.toOpaqueHexAndAlpha() + if (opacityAttributeName == null || color == null) { + setAttribute(attributeName, value) + return + } + + val (rgb, alpha) = color + setAttribute(attributeName, rgb) + if (alpha == OPAQUE) { + removeAttribute(opacityAttributeName) + } else { + setAttribute(opacityAttributeName, (alpha / OPAQUE.toFloat()).toString()) + } +} + +/** The color attributes that have a paired opacity attribute, and the attribute that holds it. */ +private val OPACITY_ATTRIBUTES = + mapOf( + "fill" to "fill-opacity", + "stroke" to "stroke-opacity", + "stop-color" to "stop-opacity", + "flood-color" to "flood-opacity", + ) + +private const val OPAQUE = 0xFF + +/** + * This hex literal split into the lowercase `#rrggbb` it names and its alpha, or `null` when it names no color. + * + * A color carries 3, 4, 6 or 8 significant digits, with the two shorthands standing for doubled pairs; an omitted + * alpha is fully opaque. + */ +private fun String.toOpaqueHexAndAlpha(): Pair? { + if (!startsWith('#')) return null + + val digits = drop(1).lowercase() + if (!digits.all { it in '0'..'9' || it in 'a'..'f' }) return null + + val expanded = + when (digits.length) { + 3, 4 -> digits.map { "$it$it" }.joinToString(separator = "") + 6, 8 -> digits + else -> return null + } + val alpha = if (expanded.length == 8) expanded.substring(6).toInt(radix = 16) else OPAQUE + return "#${expanded.substring(0, 6)}" to alpha +} diff --git a/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgPatcher.kt b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgPatcher.kt index 582d3642b0f7..4aa7e65bf1b8 100644 --- a/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgPatcher.kt +++ b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgPatcher.kt @@ -79,10 +79,10 @@ class SvgPatchOperation( /** * Whether [actualValue], the attribute's current value, satisfies this operation's condition. * - * A plain color compares case-insensitively, because hex colors and keywords are case-insensitive in SVG: - * `#6C707E` and `#6c707e` are the same color, and which one a document uses is an authoring accident. Everything - * else compares exactly — an `id`, and equally the fragment in a `fill="url(#Gradient)"` paint reference, is - * case-sensitive, so folding case there would match a different paint server. + * A plain color compares as a color, because SVG lets one color be written several ways and which one a document + * uses is an authoring accident: `#6C707E` and `#6c707e` are the same color, and so are `#fff` and `#ffffff`. + * Everything else compares exactly — an `id`, and equally the fragment in a `fill="url(#Gradient)"` paint + * reference, is case-sensitive, so folding case there would match a different paint server. * * Condition evaluation lives here rather than in each renderer so that every frontend resolves the same operation * the same way; a renderer that compared differently would draw the same icon differently. @@ -90,7 +90,7 @@ class SvgPatchOperation( @ApiStatus.Internal fun matches(actualValue: String?): Boolean = if (attributeName in COLOR_ATTRIBUTES && isPlainColor(expectedValue) && isPlainColor(actualValue)) { - actualValue.equals(expectedValue, ignoreCase = true) + sameColor(actualValue, expectedValue) } else { actualValue == expectedValue } @@ -149,3 +149,38 @@ private val COLOR_KEYWORD = Regex("[a-zA-Z]+(-[a-zA-Z]+)*") */ private fun isPlainColor(value: String?): Boolean = value != null && (HEX_COLOR.matches(value) || COLOR_KEYWORD.matches(value)) + +/** + * Whether two plain color literals denote the same color. + * + * Hex literals compare canonically, so a shorthand names the same color as the form it stands for. Any alpha the + * literal carries is not part of which color it names, because a substitution replaces the opacity of what it matched + * along with the color itself. A keyword, and a hex literal of a length that names no color, compares + * case-insensitively as text. + */ +private fun sameColor(actualValue: String?, expectedValue: String?): Boolean { + val actual = actualValue?.toRgbHex() + val expected = expectedValue?.toRgbHex() + return if (actual != null && expected != null) { + actual == expected + } else { + actualValue.equals(expectedValue, ignoreCase = true) + } +} + +/** + * The lowercase `#rrggbb` this hex literal names, or `null` when it names no color. + * + * A color carries 3, 4, 6 or 8 significant digits — the lengths `ColorHexUtil` accepts — with the two shorthands + * standing for doubled pairs. There is no valid five- or seven-digit form. + */ +private fun String.toRgbHex(): String? { + if (!startsWith('#')) return null + + val digits = drop(1).lowercase() + return when (digits.length) { + 3, 4 -> "#" + digits.take(3).map { "$it$it" }.joinToString(separator = "") + 6, 8 -> "#" + digits.take(6) + else -> null + } +} 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 index a9b324eabc89..5d1d882509bd 100644 --- 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 @@ -6,13 +6,14 @@ import androidx.compose.ui.unit.Density import com.intellij.platform.icons.ImageResourceLocation import com.intellij.platform.icons.impl.patchers.DefaultSvgPatcher import com.intellij.platform.icons.impl.patchers.SvgPatchOperation +import com.intellij.platform.icons.impl.patchers.authoredStrokeSvgPatcher import com.intellij.platform.icons.impl.patchers.strokeSvgPatcher +import com.intellij.platform.icons.impl.patchers.writeSvgAttribute import com.intellij.platform.icons.impl.rendering.DefaultImageModifiers import com.intellij.platform.icons.rendering.ImageModifiers import com.intellij.platform.icons.rendering.ImageResource import com.intellij.platform.icons.rendering.ImageResourceProvider import java.io.ByteArrayInputStream -import java.io.InputStream import javax.xml.XMLConstants import javax.xml.parsers.DocumentBuilderFactory import org.jetbrains.annotations.ApiStatus @@ -27,16 +28,15 @@ public class ComposeImageResourceProvider : ImageResourceProvider { override fun loadImage(location: ImageResourceLocation, imageModifiers: ImageModifiers?): ImageResource { if (location is PathImageResourceLocation) { val extension = location.path.substringAfterLast(".").lowercase() - val data = location.loadData(imageModifiers) - val stream = ByteArrayInputStream(data) + val resolved = location.resolve(imageModifiers) return when (extension) { "svg" -> ComposePainterImageResource( - patchSvg(imageModifiers, stream).decodeToSvgPainter(Density(1f)), + patchSvg(imageModifiers, resolved).decodeToSvgPainter(Density(1f)), imageModifiers, ) // "xml" -> loader.loadData().decodeToImageVector() - else -> ComposeBitmapImageResource(location.loadData(imageModifiers).decodeToImageBitmap()) + else -> ComposeBitmapImageResource(resolved.data.decodeToImageBitmap()) } } else { error("Unsupported loader: $location") @@ -47,14 +47,18 @@ public class ComposeImageResourceProvider : ImageResourceProvider { private val documentBuilderFactory = DocumentBuilderFactory.newDefaultInstance().apply { setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) } -private fun patchSvg(modifiers: ImageModifiers?, inputStream: InputStream): ByteArray { +private fun patchSvg(modifiers: ImageModifiers?, resolved: ResolvedImageResource): ByteArray { val builder = documentBuilderFactory.newDocumentBuilder() - val document = builder.parse(inputStream) + val document = builder.parse(ByteArrayInputStream(resolved.data)) val knownModifiers = modifiers as? DefaultImageModifiers // The palette substitution lives in the platform, so this frontend and the Swing one cannot drift into stroking - // the same icon differently. - val strokePatcher = knownModifiers?.stroke?.let { strokeSvgPatcher(it) } + // the same icon differently. Which of the two stroke patches applies follows from the file that was resolved: a + // hand-authored stroke variant is recolored as it is, while a base icon is reduced to an outline. + val strokePatcher = + knownModifiers?.stroke?.let { + if (resolved.isAuthoredStrokeVariant) authoredStrokeSvgPatcher(it) else strokeSvgPatcher(it) + } // Same order as the Swing frontend: the icon's own patcher first, the stroke substitution after it. The elvis // keeps a stroke-only icon patched, since `svgPatcher` is null whenever an icon carries no explicit patcher. val patcher = modifiers?.svgPatcher?.combineWith(strokePatcher) ?: strokePatcher @@ -65,13 +69,17 @@ private fun patchSvg(modifiers: ImageModifiers?, inputStream: InputStream): Byte /** The attribute's value, or `null` when the element does not carry it — DOM reports both as an empty string. */ private fun Element.attributeOrNull(name: String): String? = if (hasAttribute(name)) getAttribute(name) else null +private fun Element.writeAttribute(name: String, value: String) { + writeSvgAttribute(name, value, { attribute, written -> setAttribute(attribute, written) }, { removeAttribute(it) }) +} + @Suppress("NestedBlockDepth", "UnsafeCallOnNullableType") private fun DefaultSvgPatcher.patch(element: Element) { for (operation in operations) { when (operation.operation) { SvgPatchOperation.Operation.Add -> { if (!element.hasAttribute(operation.attributeName)) { - element.setAttribute(operation.attributeName, operation.value!!) + element.writeAttribute(operation.attributeName, operation.value!!) } } SvgPatchOperation.Operation.Replace -> { @@ -83,7 +91,7 @@ private fun DefaultSvgPatcher.patch(element: Element) { operation.matches(element.attributeOrNull(operation.attributeName)) != operation.negatedCondition) ) { - element.setAttribute(operation.attributeName, operation.value!!) + element.writeAttribute(operation.attributeName, operation.value!!) } } SvgPatchOperation.Operation.Remove -> { @@ -98,7 +106,7 @@ private fun DefaultSvgPatcher.patch(element: Element) { element.removeAttribute(operation.attributeName) } } - SvgPatchOperation.Operation.Set -> element.setAttribute(operation.attributeName, operation.value!!) + SvgPatchOperation.Operation.Set -> element.writeAttribute(operation.attributeName, operation.value!!) } } val nodes = element.childNodes 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 index 817db8cbb1b5..f22a8c5e94ef 100644 --- 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 @@ -2,40 +2,78 @@ package org.jetbrains.jewel.ui.icon import com.intellij.platform.icons.ImageResourceLocation +import com.intellij.platform.icons.impl.patchers.AUTHORED_STROKE_VARIANT_SUFFIX import com.intellij.platform.icons.impl.rendering.DefaultImageModifiers import com.intellij.platform.icons.rendering.ImageModifiers import org.jetbrains.annotations.ApiStatus import org.jetbrains.jewel.foundation.InternalJewelApi // TODO: Replace with ModuleImageResourceLocation and custom Loader for it +/** + * An [ImageResourceLocation] that loads image bytes from a classpath resource at [path] using [classLoader], applying + * dark-mode path modifiers. + */ @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") + /** + * Loads and returns the raw bytes of the image resource, applying dark-mode path modifiers from [imageModifiers] if + * present. + * + * @param imageModifiers Optional image modifiers (e.g., dark mode) used to select the correct resource path. + */ + public fun loadData(imageModifiers: ImageModifiers?): ByteArray = resolve(imageModifiers).data + + /** Resolves which of this location's files [imageModifiers] calls for, and reads it. */ + internal fun resolve(imageModifiers: ImageModifiers?): ResolvedImageResource { + val knownMods = + imageModifiers as? DefaultImageModifiers + ?: return ResolvedImageResource( + data = readOrNull(path) ?: error("Resource not found: $path"), + isAuthoredStrokeVariant = false, + ) + return resolve(stroked = knownMods.stroke != null, isDark = knownMods.isDark) } - private fun applyPathModifiers(path: String, modifiers: DefaultImageModifiers?): String { - if (modifiers == null) return path - return buildString { - append(path.substringBeforeLast('/', "")) - append('/') - append(path.substringBeforeLast('.').substringAfterLast('/')) - // A stroked icon loads its light artwork even in a dark theme, matching the Swing frontend: the stroke - // palette describes the light variants, so a `_dark` variant would come back with colors it does not know - // and would be left in its authored color instead of being recolored. - if (modifiers.isDark && modifiers.stroke == null) { - append("_dark") - } - append('.') - append(path.substringAfterLast('.')) + /** + * Resolves the file a stroked or dark icon is read from. + * + * A stroked icon prefers its hand-authored stroke variant, which is a separate drawing of the same glyph rather + * than a recolor of this one, and falls back to the base file for the icons that ship no such variant. Either way + * it loads light artwork, matching the Swing frontend: the stroke palette describes the light variants, so a + * `_dark` variant would come back with colors it does not know and would be left in its authored color instead of + * being recolored. + */ + internal fun resolve(stroked: Boolean, isDark: Boolean): ResolvedImageResource { + if (stroked) { + val strokePath = applyPathModifiers(path, AUTHORED_STROKE_VARIANT_SUFFIX) + val strokeData = readOrNull(strokePath) + if (strokeData != null) return ResolvedImageResource(strokeData, isAuthoredStrokeVariant = true) } + + val finalPath = applyPathModifiers(path, if (isDark && !stroked) "_dark" else "") + val data = readOrNull(finalPath) ?: error("Resource not found: $finalPath") + return ResolvedImageResource(data, isAuthoredStrokeVariant = false) + } + + private fun applyPathModifiers(path: String, suffix: String): String = buildString { + append(path.substringBeforeLast('/', "")) + append('/') + append(path.substringBeforeLast('.').substringAfterLast('/')) + append(suffix) + append('.') + append(path.substringAfterLast('.')) + } + + private fun readOrNull(path: String): ByteArray? { + val resourceStream = + if (classLoader != null) { + classLoader.getResourceAsStream(path) + } else ClassLoader.getSystemResourceAsStream(path) + return resourceStream?.use { it.readBytes() } } } + +/** The bytes a [PathImageResourceLocation] resolved to, and which of its files they came from. */ +internal class ResolvedImageResource(val data: ByteArray, val isAuthoredStrokeVariant: Boolean) diff --git a/platform/jewel/ui/src/test/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocationTest.kt b/platform/jewel/ui/src/test/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocationTest.kt new file mode 100644 index 000000000000..87ce3666cf7d --- /dev/null +++ b/platform/jewel/ui/src/test/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocationTest.kt @@ -0,0 +1,67 @@ +// 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.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins which file a stroked icon is read from. + * + * An icon may ship a hand-authored stroke variant: a separate drawing of the same glyph, already reduced to an outline. + * Recoloring the base drawing is not a substitute for it — `strokeVariant.svg` is a filled shape inside an outline + * whose color is outside the stroke palette, so no palette substitution turns it into the outlined glyph that + * `strokeVariant_stroke.svg` holds. Icons that ship no such variant have to keep resolving to their base file. + */ +internal class PathImageResourceLocationTest { + @Test + fun `a stroked icon resolves its hand-authored stroke variant`() { + val resolved = location("icons/strokeVariant.svg").resolve(stroked = true, isDark = false) + + assertTrue(resolved.isAuthoredStrokeVariant) + assertEquals(contentOf("icons/strokeVariant_stroke.svg"), resolved.data.decodeToString()) + } + + @Test + fun `a stroked icon without a variant falls back to its base file`() { + val resolved = location("icons/search.svg").resolve(stroked = true, isDark = false) + + assertFalse(resolved.isAuthoredStrokeVariant) + assertEquals(contentOf("icons/search.svg"), resolved.data.decodeToString()) + } + + @Test + fun `a stroked icon takes light artwork in a dark theme`() { + // The stroke palette describes the light variants, so a stroked icon resolves the variant rather than `_dark` + // — and falls back to the light base file, not the dark one, when it ships no variant. + assertTrue(location("icons/strokeVariant.svg").resolve(stroked = true, isDark = true).isAuthoredStrokeVariant) + assertEquals( + contentOf("icons/search.svg"), + location("icons/search.svg").resolve(stroked = true, isDark = true).data.decodeToString(), + ) + } + + @Test + fun `a dark icon that is not stroked still resolves its dark variant`() { + val resolved = location("icons/search.svg").resolve(stroked = false, isDark = true) + + assertFalse(resolved.isAuthoredStrokeVariant) + assertEquals(contentOf("icons/search_dark.svg"), resolved.data.decodeToString()) + } + + @Test + fun `an icon that is not stroked never resolves the variant`() { + val resolved = location("icons/strokeVariant.svg").resolve(stroked = false, isDark = false) + + assertFalse(resolved.isAuthoredStrokeVariant) + assertEquals(contentOf("icons/strokeVariant.svg"), resolved.data.decodeToString()) + } + + private fun location(path: String) = PathImageResourceLocation(path, javaClass.classLoader) + + private fun contentOf(path: String): String = + checkNotNull(javaClass.classLoader.getResourceAsStream(path)) { "missing test resource: $path" } + .use { it.readBytes() } + .decodeToString() +} diff --git a/platform/jewel/ui/src/test/resources/icons/strokeVariant.svg b/platform/jewel/ui/src/test/resources/icons/strokeVariant.svg new file mode 100644 index 000000000000..4081872d54be --- /dev/null +++ b/platform/jewel/ui/src/test/resources/icons/strokeVariant.svg @@ -0,0 +1,4 @@ + + + + diff --git a/platform/jewel/ui/src/test/resources/icons/strokeVariant_stroke.svg b/platform/jewel/ui/src/test/resources/icons/strokeVariant_stroke.svg new file mode 100644 index 000000000000..fea00aec867c --- /dev/null +++ b/platform/jewel/ui/src/test/resources/icons/strokeVariant_stroke.svg @@ -0,0 +1,4 @@ + + + +