Skip to content

Commit 6b4d9c0

Browse files
authored
Render real item icons in the IntelliJ inventory preview (#845)
1 parent 0ba6a84 commit 6b4d9c0

8 files changed

Lines changed: 610 additions & 16 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
.kotlin/
44
build/
55
libs/
6-
.intellijPlatform/
6+
.intellijPlatform/
7+
CLAUDE.md

intellij-plugin/TOOLING_SUPPORT.md

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ the user's code, so anything that depends on runtime state can only ever be appr
3333
- `withItem(new ItemStack(Material.X))`, resolved directly or through a local variable/parameter
3434
initializer, for: `slot(index, item)`, `slot(index).withItem(item)`, `firstSlot(item)`,
3535
`lastSlot(item)`, `layoutSlot(char, item)` (both the direct and chained-builder forms).
36+
- Falls back to any `Material.X` passed directly as an argument to a helper call when there's no
37+
`new ItemStack(...)` at all - e.g. `withItem(ExampleUtil.displayItem(Material.STONE, "Label"))`.
38+
The helper's body is never evaluated (this is static analysis, not execution); the Material
39+
argument is just a strong enough signal on its own to use as a best-effort guess. Only looks at
40+
the call's own arguments, or (through a local variable) its initializer's - not into further
41+
nested calls.
3642
- `row(n)` / `firstRow()` / `lastRow()` / `column(n)` / `firstColumn()` / `lastColumn()`, both the
3743
chained (`.withItem(item)`) and `BiConsumer` factory (`(pos, slot) -> slot.withItem(item)`)
3844
forms — see limitations below for the heuristic these rely on.
@@ -45,17 +51,40 @@ the user's code, so anything that depends on runtime state can only ever be appr
4551
nearest-neighbor interpolation to keep the pixel art crisp. Slot content (material color+label,
4652
dynamic marker, layout fill) is overlaid at the sprite's real slot positions.
4753
- Every other view type still renders as a plain drawn grid — there's no sprite for them.
54+
- **Real item icons** (`ItemIconProvider`), opportunistically: if the machine running the IDE has
55+
a vanilla Minecraft client installed, icons are read directly from that client jar at render
56+
time — nothing is bundled or redistributed by the plugin itself, since Mojang's usage guidelines
57+
prohibit that for third-party tools. Rather than recognizing a fixed set of shapes, a material's
58+
actual model geometry (`"elements"`, each a cuboid with per-face textures) is read and rendered:
59+
the model chain is walked from the material's leaf model — found directly, or, for materials
60+
like fences/walls whose real icon model is only reachable through the newer
61+
`assets/minecraft/items/<material>.json` indirection, through that — up through `"parent"`,
62+
merging each level's `"textures"` until one with `"elements"` is found. Every element's three
63+
camera-visible faces (top, and the two visible sides) are projected through a fixed dimetric
64+
camera and composited depth-sorted (nearer elements drawn over farther ones), rasterized
65+
per-pixel at 4x supersampling and box-filtered back down for a clean antialiased silhouette
66+
instead of jagged or blurred seams. This is what makes a stair render as an actual step shape (a
67+
slab plus a raised quarter-block, exactly per its own model), a fence or wall show its posts and
68+
bars, and a torch show as a thin stick with a flame top — not just a fixed cube or a flat square.
69+
Materials whose model never resolves to any elements (flat tool/food/"item/generated" icons, or a
70+
model type this doesn't follow — see below) render as a flat square using the same texture
71+
resolution instead. Animated textures are cropped to their first frame. When no client jar can be
72+
found, slots fall back to the original colored square + 3-letter material abbreviation. (The
73+
bundled chest frame sprites are original/generic art, not extracted Mojang textures, so they
74+
don't carry the same restriction and are unaffected either way.) The `.minecraft` directory used
75+
for auto-detection can be overridden per-machine in **Settings > Tools > Inventory Framework**
76+
(`MinecraftIconSettings`), for setups the platform default guess can't find (portable/custom
77+
launchers, an install on another drive, etc.).
78+
- Known gaps in this renderer specifically: biome tinting (grass/leaves/water show their
79+
texture's own base color, not the tinted one — no biome context exists to tint with); items
80+
whose model uses a `select`/`special`/`condition` type in `items/<material>.json` (chests,
81+
compasses, spawn eggs, ...) rather than a plain `"minecraft:model"` reference; per-face texture
82+
`"rotation"` hints (ignored, so a rotated face's texture shows unrotated); and multi-layer flat
83+
icons (dyed leather armor's `layer1`, potion overlay colors, etc. - only the first resolvable
84+
layer is used). All of these fall back to a flat texture or the placeholder, never a crash.
4885

4986
## Known limitations / not supported
5087

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.)
5988
- **Nothing dynamic is ever evaluated.** Non-literal titles, `renderWith`/`onRender` lambdas,
6089
`displayIf`/state-driven conditions, and any item expression that isn't a literal
6190
`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) {

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ object ItemExtractor {
216216
val expr = rawExpr.skipParenthesizedExprDown()
217217
if (isNullLiteral(expr)) return null
218218
val material = findItemStackConstructorCall(expr)?.let { extractMaterialName(it) }
219+
?: findMaterialArgument(expr)
219220
return PreviewSlot(material = material, dynamic = material == null)
220221
}
221222

@@ -242,4 +243,31 @@ object ItemExtractor {
242243
if (field.containingClass?.qualifiedName != MATERIAL_FQN) return null
243244
return field.name
244245
}
246+
247+
// Not every item comes from a bare `new ItemStack(Material.X)` - a common idiom is a helper
248+
// method that builds one from a Material, e.g. `ExampleUtil.displayItem(Material.STONE,
249+
// "Label")`. There's no way to evaluate what such a method actually returns (this is static
250+
// analysis, not execution), but the Material passed in is still a strong, deterministic signal
251+
// of what the item will be, so it's used directly as a best-effort guess. Only looks at the
252+
// call's own arguments (or, through a local variable, its initializer's) - not into nested
253+
// calls - mirroring findItemStackConstructorCall's one-level indirection.
254+
private fun findMaterialArgument(rawExpr: UExpression): String? {
255+
val call = asCallExpression(rawExpr)
256+
if (call != null) {
257+
return call.valueArguments.firstNotNullOfOrNull(::materialFieldName)
258+
}
259+
260+
val expr = rawExpr.skipParenthesizedExprDown()
261+
val ref = expr as? UReferenceExpression ?: return null
262+
val variable = ref.resolve()?.toUElementOfType<UVariable>() ?: return null
263+
val initializer = variable.uastInitializer ?: return null
264+
return findMaterialArgument(initializer)
265+
}
266+
267+
private fun materialFieldName(rawExpr: UExpression): String? {
268+
val expr = rawExpr.skipParenthesizedExprDown()
269+
val field = (expr as? UReferenceExpression)?.resolve() as? PsiField ?: return null
270+
if (field.containingClass?.qualifiedName != MATERIAL_FQN) return null
271+
return field.name
272+
}
245273
}

0 commit comments

Comments
 (0)