mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
[JEWEL-1370] Align stroking on the new icons API with the legacy path
Follow-up to JEWEL-1369. Comparing the new cross-frontend icons API (com.intellij.platform.icons) against the legacy IconLoader/SVGLoader stroking, which is the reference implementation, surfaced three further behaviours where the two still disagreed. All three are long-standing gaps rather than regressions, and the legacy path is left untouched. Hand-authored _stroke.svg variants took the wrong patch. Such a variant is a separate drawing, already reduced to an outline by hand and authored in white, so the legacy path only recolors it; the new API applied the full palette substitution instead, including turning background tints transparent. authoredStrokeSvgPatcher now sits beside strokeSvgPatcher, and each frontend picks one from the file it resolved. Stroking such a variant in opaque white patches nothing, matching the legacy short-circuit, so a variant carrying its own opacity is left exactly as authored. The Compose frontend did not look for that variant at all, so a stroked expui/run/run.svg rendered as the base file with its off-palette green outline intact, ignoring the requested color entirely. It now resolves the variant the way the Swing frontend already did, and falls back to light artwork rather than a dark variant when the icon ships none. Opacity and alpha were ignored. The fill and stroke attributes carry no alpha channel — alpha belongs on fill-opacity and its siblings — so a translucent stroke color was written out as #RRGGBBAA, and an opacity the document was authored with went on shading a color that had just been replaced. writeSvgAttribute splits a color across the pair, which is what the legacy patcher does through its alphaProvider. Both frontends write through it, for the same reason both already resolve conditions through SvgPatchOperation.matches. Short hex forms never matched. Attribute values were compared as text, so fill="#fff" missed the palette's #ffffff. Color comparison now canonicalizes hex literals, expanding the three- and four-digit shorthands. It compares the color alone: an alpha the literal carries is not part of its identity, since a substitution replaces the opacity of what it matched along with the color. A literal of a length that names no color, such as five digits, keeps comparing as text rather than being expanded into a color it does not name. closes https://github.com/JetBrains/intellij-community/pull/3588 (cherry picked from commit c67d6706ac6824faf1ccc439c11d2002ad706eec) IJ-MR-220560 GitOrigin-RevId: 5f3f3d75ecd8a968958053c1f0ceb180b13ddeb7
This commit is contained in:
committed by
intellij-monorepo-bot
parent
6e788f989a
commit
5552a03bb3
+54
-18
@@ -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<String, String>) {
|
||||
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) {
|
||||
|
||||
+9
-9
@@ -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
|
||||
|
||||
+50
-4
@@ -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<SvgPatchOperation> = operations(),
|
||||
): SvgPatchOperation? =
|
||||
operations.singleOrNull {
|
||||
it.attributeName == attribute &&
|
||||
it.operation == SvgPatchOperation.Operation.Replace &&
|
||||
it.conditional &&
|
||||
it.expectedValue == expectedValue
|
||||
}
|
||||
|
||||
private fun operations(): List<SvgPatchOperation> {
|
||||
val patcher = strokeSvgPatcher(red) as? DefaultSvgPatcher
|
||||
private fun operations(authored: Boolean = false, stroke: Color = red): List<SvgPatchOperation> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -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)))
|
||||
|
||||
+75
@@ -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<String, String> = emptyMap(),
|
||||
): Map<String, String> {
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
+26
@@ -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
|
||||
|
||||
@@ -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<String> = 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.
|
||||
*
|
||||
|
||||
+70
@@ -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<String, Int>? {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+20
-12
@@ -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
|
||||
|
||||
+60
-22
@@ -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)
|
||||
|
||||
+67
@@ -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()
|
||||
}
|
||||
@@ -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. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5 7.13397C14.1667 7.51888 14.1667 8.48112 13.5 8.86602L4.5 14.0622C3.83333 14.4471 3 13.966 3 13.1962L3 2.80385C3 2.03405 3.83333 1.55292 4.5 1.93782L13.5 7.13397Z" fill="#F2FCF3" stroke="#208A3C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 446 B |
@@ -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. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5 7.13397C14.1667 7.51888 14.1667 8.48113 13.5 8.86603L4.5 14.0622C3.83333 14.4471 3 13.966 3 13.1962L3 2.80385C3 2.03405 3.83333 1.55292 4.5 1.93782L13.5 7.13397Z" stroke="white" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 448 B |
Reference in New Issue
Block a user