Skip to content

Commit ee320d3

Browse files
devnatanclaude
andcommitted
Render real item icons in the IntelliJ inventory preview
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 0ba6a84 commit ee320d3

6 files changed

Lines changed: 286 additions & 15 deletions

File tree

intellij-plugin/TOOLING_SUPPORT.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,26 @@ the user's code, so anything that depends on runtime state can only ever be appr
4545
nearest-neighbor interpolation to keep the pixel art crisp. Slot content (material color+label,
4646
dynamic marker, layout fill) is overlaid at the sprite's real slot positions.
4747
- Every other view type still renders as a plain drawn grid — there's no sprite for them.
48+
- **Real item icons** (`ItemIconProvider`), opportunistically: if the machine running the IDE has
49+
a vanilla Minecraft client installed, icons are read directly from that client jar's
50+
`assets/minecraft/textures/{item,block}/<material>.png` at render time — nothing is bundled or
51+
redistributed by the plugin itself, since Mojang's usage guidelines prohibit that for
52+
third-party tools. Animated textures are cropped to their first frame and anything above 16x16
53+
is downscaled. If a material has no same-named texture file (e.g. a stained glass pane's icon is
54+
really just its plain glass block's texture), the reference is read out of the item's own
55+
`assets/minecraft/models/item/<material>.json` instead - one model deep, without following
56+
parent chains or resolving `"#variable"` texture substitution, so composite block-shaped items
57+
whose icon only inherits a texture from a parent model (fences, walls, carpets, stairs, ...)
58+
still fall back to the placeholder. When no client jar can be found, slots fall back to the
59+
original colored square + 3-letter material abbreviation. (The bundled chest frame sprites are
60+
original/generic art, not extracted Mojang textures, so they don't carry the same restriction and
61+
are unaffected either way.) The `.minecraft` directory used for auto-detection can be overridden
62+
per-machine in **Settings > Tools > Inventory Framework** (`MinecraftIconSettings`), for setups
63+
the platform default guess can't find (portable/custom launchers, an install on another drive,
64+
etc.).
4865

4966
## Known limitations / not supported
5067

51-
- **No real item icons.** Items still render as a deterministic colored square + a 3-letter
52-
material abbreviation, not actual item textures — only the chest *frame* is a real sprite, not
53-
the items placed inside it. Mojang's usage guidelines prohibit redistributing or serving game
54-
assets from a tool, which ruled out both bundling an item texture pack and fetching from any
55-
hosted API (including reputable-looking third-party ones). The compliant path — reading item
56-
textures from a client jar the user already owns, entirely locally — was scoped out as a
57-
separate follow-up, not built in this pass. (The bundled chest frame sprites are original/generic
58-
art, not extracted Mojang textures, so they don't carry the same restriction.)
5968
- **Nothing dynamic is ever evaluated.** Non-literal titles, `renderWith`/`onRender` lambdas,
6069
`displayIf`/state-driven conditions, and any item expression that isn't a literal
6170
`new ItemStack(Material.X)` (directly or via a simple local variable) all show as a generic

intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewPanel.kt

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -306,25 +306,36 @@ class InventoryPreviewPanel : JPanel() {
306306
// isn't "filled" - we just don't know what's actually rendered there - so it's still
307307
// subject to the empty-slot toggle like any other unfilled slot.
308308
val isLayoutPlaceholder = !isFilled && layoutChar != null && layoutChar != ' '
309+
// Only resolves to a real texture if the user has a local Minecraft client jar installed
310+
// (see ItemIconProvider); otherwise null and the colored-square placeholder below is used.
311+
val icon = slot?.material?.let { ItemIconProvider.iconFor(it) }
309312

310313
if (isFilled || ((paintEmptyBackground || isLayoutPlaceholder) && showEmptySlots)) {
311-
g.color = when {
312-
slot?.dynamic == true -> JBColor.YELLOW
313-
slot?.material != null -> colorForMaterial(slot.material)
314-
isLayoutPlaceholder -> JBColor.LIGHT_GRAY
315-
else -> JBColor.GRAY
314+
if (icon == null) {
315+
g.color = when {
316+
slot?.dynamic == true -> JBColor.YELLOW
317+
slot?.material != null -> colorForMaterial(slot.material)
318+
isLayoutPlaceholder -> JBColor.LIGHT_GRAY
319+
else -> JBColor.GRAY
320+
}
321+
g.fillRect(x + 1, y + 1, size - 2, size - 2)
316322
}
317-
g.fillRect(x + 1, y + 1, size - 2, size - 2)
318323
if (paintEmptyBackground) {
319324
g.color = JBColor.DARK_GRAY
320325
g.drawRect(x, y, size, size)
321326
}
322327
}
323328

329+
if (icon != null) {
330+
(g as Graphics2D).setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR)
331+
val inset = size / 16
332+
g.drawImage(icon, x + inset, y + inset, size - inset * 2, size - inset * 2, null)
333+
}
334+
324335
g.color = Color.BLACK
325336
when {
326337
slot?.dynamic == true -> g.drawString("?", x + size / 2 - 3, y + size / 2 + 5)
327-
slot?.material != null -> g.drawString(abbreviateMaterial(slot.material), x + 3, y + size - 4)
338+
slot?.material != null && icon == null -> g.drawString(abbreviateMaterial(slot.material), x + 3, y + size - 4)
328339
}
329340

330341
if (index in highlightedSlotIndices) {
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package me.devnatan.inventoryframework.intellij
2+
3+
import java.awt.RenderingHints
4+
import java.awt.image.BufferedImage
5+
import java.io.File
6+
import java.util.Locale
7+
import java.util.zip.ZipFile
8+
import javax.imageio.ImageIO
9+
10+
private const val ICON_SIZE = 16
11+
private const val TEXTURE_ROOT = "assets/minecraft/textures"
12+
private const val MODEL_ROOT = "assets/minecraft/models"
13+
14+
// Preference order when a model declares more than one texture layer/face - layer0 is the
15+
// standard flat-icon key ("item/generated" models), the rest are common cube-model face names;
16+
// picking any single one is only ever an approximation of the real (sometimes multi-layered or
17+
// tinted) icon, but it's a much closer one than the placeholder.
18+
private val TEXTURE_KEY_PRIORITY = listOf("layer0", "all", "side", "particle", "texture", "top", "cross")
19+
20+
// Reads item icons directly from a Minecraft client jar already installed on this machine, on
21+
// demand and entirely locally - the plugin itself never bundles or serves any extracted game
22+
// asset, since Mojang's usage guidelines prohibit that for third-party tools (see
23+
// "No real item icons" in TOOLING_SUPPORT.md). If no client jar can be found, every lookup
24+
// returns null and callers fall back to the existing placeholder rendering.
25+
object ItemIconProvider {
26+
27+
// Sentinel distinguishing "never resolved a jar yet" from a resolved-but-null result, so a
28+
// client jar that's genuinely missing isn't retried (and re-scanned) on every single lookup.
29+
private object Unresolved
30+
31+
private var resolvedForSetting: Any? = Unresolved
32+
private var cachedJar: ZipFile? = null
33+
private val cache = mutableMapOf<String, BufferedImage?>()
34+
35+
@Synchronized
36+
fun iconFor(material: String): BufferedImage? {
37+
val key = material.lowercase(Locale.ROOT)
38+
return cache.getOrPut(key) { loadIcon(key) }
39+
}
40+
41+
private fun loadIcon(name: String): BufferedImage? {
42+
val jar = clientJar() ?: return null
43+
val direct = readEntry(jar, "$TEXTURE_ROOT/item/$name.png") ?: readEntry(jar, "$TEXTURE_ROOT/block/$name.png")
44+
val raw = direct ?: modelTexturePath(jar, name)?.let { readEntry(jar, "$TEXTURE_ROOT/$it.png") }
45+
return raw?.let(::normalize)
46+
}
47+
48+
// Some items have no texture file that matches their own name - a stained glass pane's icon,
49+
// for instance, is just its plain glass block's texture (see models/item/*_pane.json's
50+
// "layer0"), not a "*_pane.png" that doesn't exist. Rather than hardcoding every such case,
51+
// read the reference straight out of the item's own model JSON. Only resolves one model deep
52+
// (no parent-chain walk, no "#variable" texture substitution), so composite block-shaped
53+
// items whose model only inherits a texture from a parent (fences, walls, carpets, stairs...)
54+
// are a known remaining gap - they still fall back to the placeholder, same as before.
55+
private fun modelTexturePath(jar: ZipFile, name: String): String? {
56+
val json = readText(jar, "$MODEL_ROOT/item/$name.json") ?: return null
57+
val texturesBlock = Regex(""""textures"\s*:\s*\{([^}]*)}""").find(json)?.groupValues?.get(1) ?: return null
58+
val entries = Regex(""""(\w+)"\s*:\s*"([^"]+)"""").findAll(texturesBlock)
59+
.associate { it.groupValues[1] to it.groupValues[2] }
60+
val value = TEXTURE_KEY_PRIORITY.firstNotNullOfOrNull { entries[it] } ?: entries.values.firstOrNull()
61+
return value?.removePrefix("minecraft:")?.takeUnless { it.startsWith("#") }
62+
}
63+
64+
private fun readText(jar: ZipFile, path: String): String? {
65+
val entry = jar.getEntry(path) ?: return null
66+
return jar.getInputStream(entry).use { runCatching { it.readBytes().toString(Charsets.UTF_8) }.getOrNull() }
67+
}
68+
69+
// Re-resolves whenever the configured override changes (e.g. the dev just saved a new path in
70+
// Settings > Tools > Inventory Framework), rather than caching it for the plugin's whole
71+
// lifetime like a plain `by lazy` would - otherwise editing the setting would need an IDE
72+
// restart to take effect.
73+
@Synchronized
74+
private fun clientJar(): ZipFile? {
75+
val configuredHome = MinecraftIconSettings.getInstance().minecraftHome.ifBlank { null }
76+
if (resolvedForSetting != configuredHome) {
77+
resolvedForSetting = configuredHome
78+
cache.clear()
79+
cachedJar = locateClientJar(configuredHome)?.let { runCatching { ZipFile(it) }.getOrNull() }
80+
}
81+
return cachedJar
82+
}
83+
84+
private fun readEntry(jar: ZipFile, path: String): BufferedImage? {
85+
val entry = jar.getEntry(path) ?: return null
86+
return jar.getInputStream(entry).use { runCatching { ImageIO.read(it) }.getOrNull() }
87+
}
88+
89+
// Animated textures are stored as a vertical strip of square frames (width == frame size);
90+
// the first frame is the top width-by-width square. Anything still bigger than one icon cell
91+
// afterwards (e.g. 32x32 items) is then downscaled.
92+
private fun normalize(image: BufferedImage): BufferedImage {
93+
val square = if (image.width != image.height) {
94+
image.getSubimage(0, 0, image.width, minOf(image.width, image.height))
95+
} else {
96+
image
97+
}
98+
if (square.width == ICON_SIZE && square.height == ICON_SIZE) return square
99+
100+
val scaled = BufferedImage(ICON_SIZE, ICON_SIZE, BufferedImage.TYPE_INT_ARGB)
101+
val g = scaled.createGraphics()
102+
try {
103+
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
104+
g.drawImage(square, 0, 0, ICON_SIZE, ICON_SIZE, null)
105+
} finally {
106+
g.dispose()
107+
}
108+
return scaled
109+
}
110+
111+
// Picks the newest installed release-named version whose jar actually contains item
112+
// textures, falling back to whatever else is there (e.g. a modloader profile jar) sorted by
113+
// recency. Modloader profiles set up by the vanilla launcher often "inheritsFrom" a vanilla
114+
// version instead of bundling assets themselves - those are skipped by the texture check
115+
// rather than treated as an error, since the real vanilla version is usually also installed.
116+
//
117+
// `configuredHome` is the dev's override from Settings > Tools > Inventory Framework
118+
// (MinecraftIconSettings); null means it's unset and the platform default guess is used.
119+
private fun locateClientJar(configuredHome: String?): File? {
120+
val home = configuredHome?.let(::File) ?: minecraftHome() ?: return null
121+
val versionsDir = File(home, "versions").takeIf { it.isDirectory } ?: return null
122+
val candidates = versionsDir.listFiles { f -> f.isDirectory }
123+
?.mapNotNull { dir -> File(dir, "${dir.name}.jar").takeIf { it.isFile } }
124+
?: return null
125+
126+
val releasePattern = Regex("""^\d+\.\d+(\.\d+)?$""")
127+
val releases = candidates.filter { releasePattern.matches(it.parentFile.name) }
128+
.sortedByDescending { versionSortKey(it.parentFile.name) }
129+
val rest = candidates.filterNot { it in releases }.sortedByDescending { it.lastModified() }
130+
131+
return (releases + rest).firstOrNull(::hasItemTextures)
132+
}
133+
134+
private fun versionSortKey(version: String): Int {
135+
val parts = version.split('.').map { it.toIntOrNull() ?: 0 }
136+
return parts.getOrElse(0) { 0 } * 1_000_000 + parts.getOrElse(1) { 0 } * 1_000 + parts.getOrElse(2) { 0 }
137+
}
138+
139+
private fun hasItemTextures(jar: File): Boolean =
140+
runCatching { ZipFile(jar).use { it.getEntry("$TEXTURE_ROOT/item/apple.png") != null } }.getOrDefault(false)
141+
142+
private fun minecraftHome(): File? {
143+
val home = System.getProperty("user.home") ?: return null
144+
val os = System.getProperty("os.name")?.lowercase(Locale.ROOT).orEmpty()
145+
val dir = when {
146+
os.contains("win") -> System.getenv("APPDATA")?.let { File(it, ".minecraft") } ?: File(home, "AppData/Roaming/.minecraft")
147+
os.contains("mac") -> File(home, "Library/Application Support/minecraft")
148+
else -> File(home, ".minecraft")
149+
}
150+
return dir.takeIf { it.isDirectory }
151+
}
152+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package me.devnatan.inventoryframework.intellij
2+
3+
import com.intellij.openapi.application.ApplicationManager
4+
import com.intellij.openapi.components.PersistentStateComponent
5+
import com.intellij.openapi.components.Service
6+
import com.intellij.openapi.components.State
7+
import com.intellij.openapi.components.Storage
8+
import com.intellij.util.xmlb.XmlSerializerUtil
9+
10+
// Lets a dev override where ItemIconProvider looks for a Minecraft client jar, for setups the
11+
// platform-default guess (see ItemIconProvider.minecraftHome) can't find - a portable/custom
12+
// launcher, an install on another drive, etc. Empty means "auto-detect".
13+
@Service(Service.Level.APP)
14+
@State(name = "InventoryFrameworkMinecraftSettings", storages = [Storage("inventoryframework-minecraft.xml")])
15+
class MinecraftIconSettings : PersistentStateComponent<MinecraftIconSettings.State> {
16+
17+
class State {
18+
var minecraftHome: String = ""
19+
}
20+
21+
private var state = State()
22+
23+
var minecraftHome: String
24+
get() = state.minecraftHome
25+
set(value) {
26+
state.minecraftHome = value.trim()
27+
}
28+
29+
override fun getState(): State = state
30+
31+
override fun loadState(state: State) {
32+
XmlSerializerUtil.copyBean(state, this.state)
33+
}
34+
35+
companion object {
36+
fun getInstance(): MinecraftIconSettings =
37+
ApplicationManager.getApplication().getService(MinecraftIconSettings::class.java)
38+
}
39+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package me.devnatan.inventoryframework.intellij
2+
3+
import com.intellij.openapi.fileChooser.FileChooser
4+
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
5+
import com.intellij.openapi.options.Configurable
6+
import com.intellij.openapi.ui.TextFieldWithBrowseButton
7+
import com.intellij.util.ui.FormBuilder
8+
import javax.swing.JComponent
9+
import javax.swing.JLabel
10+
11+
class MinecraftIconSettingsConfigurable : Configurable {
12+
13+
private var homeField: TextFieldWithBrowseButton? = null
14+
15+
override fun getDisplayName(): String = "Inventory Framework"
16+
17+
override fun createComponent(): JComponent {
18+
val field = TextFieldWithBrowseButton()
19+
field.addActionListener {
20+
val descriptor = FileChooserDescriptorFactory.createSingleFolderDescriptor()
21+
.withTitle("Minecraft Home Directory")
22+
.withDescription("Folder containing \"versions\" - usually named .minecraft.")
23+
val chosen = FileChooser.chooseFile(descriptor, null, null) ?: return@addActionListener
24+
field.text = chosen.path
25+
}
26+
homeField = field
27+
28+
val comment = JLabel(
29+
"<html>Used to render real item icons in the inventory preview by reading textures from an " +
30+
"installed client jar. Leave empty to auto-detect the platform default " +
31+
"(%APPDATA%/.minecraft, ~/.minecraft, or ~/Library/Application Support/minecraft).</html>",
32+
)
33+
34+
return FormBuilder.createFormBuilder()
35+
.addLabeledComponent("Minecraft home (.minecraft) directory:", field)
36+
.addComponentToRightColumn(comment)
37+
.addComponentFillVertically(JLabel(), 0)
38+
.panel
39+
}
40+
41+
override fun isModified(): Boolean =
42+
homeField?.text?.trim().orEmpty() != MinecraftIconSettings.getInstance().minecraftHome
43+
44+
override fun apply() {
45+
MinecraftIconSettings.getInstance().minecraftHome = homeField?.text.orEmpty()
46+
}
47+
48+
override fun reset() {
49+
homeField?.text = MinecraftIconSettings.getInstance().minecraftHome
50+
}
51+
52+
override fun disposeUIResources() {
53+
homeField = null
54+
}
55+
}

intellij-plugin/src/main/resources/META-INF/plugin.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313

1414
<extensions defaultExtensionNs="com.intellij">
1515
<fileEditorProvider implementation="me.devnatan.inventoryframework.intellij.InventoryPreviewFileEditorProvider"/>
16+
<applicationConfigurable
17+
parentId="tools"
18+
instance="me.devnatan.inventoryframework.intellij.MinecraftIconSettingsConfigurable"
19+
id="me.devnatan.inventoryframework.intellij.MinecraftIconSettingsConfigurable"
20+
displayName="Inventory Framework"/>
1621
<localInspection
1722
language=""
1823
groupName="Inventory Framework"

0 commit comments

Comments
 (0)