From 660e6bac9802295caf8dfc6b5143319228918677 Mon Sep 17 00:00:00 2001 From: Natan Date: Thu, 6 Aug 2026 19:32:00 -0300 Subject: [PATCH] feat: navigation between views interaction support --- .../intellij/AvailableSlotResolver.kt | 79 ++++++++++++++++++ .../intellij/ClickHandlerExtractor.kt | 21 +++++ .../intellij/ForLoopAnalyzer.kt | 77 +++++++++++++++++ .../intellij/InventoryPreviewFileEditor.kt | 82 ++++++++++++++++++- .../intellij/InventoryPreviewPanel.kt | 51 ++++++++++++ .../intellij/PreviewInteractionState.kt | 3 + .../intellij/PreviewModel.kt | 1 + .../intellij/PreviewNavigationHistory.kt | 47 +++++++++++ .../intellij/SlotTargetResolver.kt | 15 +++- 9 files changed, 370 insertions(+), 6 deletions(-) create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/AvailableSlotResolver.kt create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ForLoopAnalyzer.kt create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewNavigationHistory.kt diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/AvailableSlotResolver.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/AvailableSlotResolver.kt new file mode 100644 index 00000000..925c345e --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/AvailableSlotResolver.kt @@ -0,0 +1,79 @@ +package me.devnatan.inventoryframework.intellij + +import me.devnatan.inventoryframework.internal.LayoutSlot +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UFile +import org.jetbrains.uast.UForExpression +import org.jetbrains.uast.getParentOfType +import org.jetbrains.uast.visitor.AbstractUastVisitor + +private const val FRAMEWORK_PACKAGE_PREFIX = "me.devnatan.inventoryframework" +private const val AVAILABLE_SLOT_METHOD = "availableSlot" + +// Mirrors AvailableSlotInterceptor in inventory-framework-core, which can't be called directly: +// it resolves availableSlot(...) calls against a live IFRenderContext built while the user's code +// actually runs, and this plugin never compiles or executes that code - only its call sites are +// known statically. So the two algorithms (resolveFromInitialSlot / resolveFromLayoutSlot) are +// reimplemented here against the plugin's own statically-collected data instead. +internal object AvailableSlotResolver { + + // Every availableSlot(...) call site claims exactly one slot per execution, in registration + // (i.e. source/loop-iteration) order, regardless of whether it ends up binding an item or a + // click handler ItemExtractor/ClickHandlerExtractor can recognize - so all call sites must be + // counted here, not just the ones with a resolvable binding, to keep later calls' assigned + // slots correct. A call site directly inside a simple bounded counting loop (see + // ForLoopAnalyzer) is counted once per statically-known iteration - the idiomatic way to + // batch-fill available slots - rather than once total; anything else (nested loops, for-each, + // while, non-i++/i-- steps) falls back to counting it once, same as a bare call outside a loop. + fun collectAnchors(uFile: UFile): List { + val anchors = mutableListOf() + uFile.accept(object : AbstractUastVisitor() { + override fun visitCallExpression(node: UCallExpression): Boolean { + val method = node.resolve() ?: return false + val declaringClass = method.containingClass?.qualifiedName ?: return false + if (node.methodName != AVAILABLE_SLOT_METHOD || !declaringClass.startsWith(FRAMEWORK_PACKAGE_PREFIX)) { + return false + } + val anchor = SlotTargetResolver.anchorOf(node) ?: return false + val enclosingLoop = node.getParentOfType(strict = true) + val repeats = enclosingLoop?.let { ForLoopAnalyzer.analyze(it)?.values?.size } ?: 1 + repeat(repeats.coerceAtLeast(0)) { anchors += anchor } + return false + } + }) + return anchors + } + + // Without a layout, slots fill sequentially from 0 (resolveFromInitialSlot); with one, only + // positions marked with the layout's reserved fill character are candidates + // (resolveFromLayoutSlot). Either way, slots already claimed by an explicit binding + // (slot/layoutSlot/row/column) are skipped, and calls beyond the container's capacity are + // silently dropped rather than shown overflowing - the plugin has no error/diagnostic surface + // for a preview-time SlotFillExceededException. Returned as a list per anchor because a single + // call site inside a loop claims several slots, all bound to the same statically-extracted item. + fun resolve( + anchors: List, + occupiedSlots: Set, + layout: List?, + columns: Int, + maxSize: Int, + ): Map> { + val candidates = if (layout != null) { + buildList { + layout.forEachIndexed { row, rowChars -> + rowChars.forEachIndexed { col, character -> + if (character == LayoutSlot.FILLED_RESERVED_CHAR) add(row * columns + col) + } + } + } + } else { + (0 until maxSize).toList() + }.filterNot { it in occupiedSlots } + + val resolved = mutableMapOf>() + anchors.forEachIndexed { i, anchor -> + candidates.getOrNull(i)?.let { resolved.getOrPut(anchor) { mutableListOf() }.add(it) } + } + return resolved + } +} diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ClickHandlerExtractor.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ClickHandlerExtractor.kt index e582ee96..676a8c98 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ClickHandlerExtractor.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ClickHandlerExtractor.kt @@ -1,8 +1,10 @@ package me.devnatan.inventoryframework.intellij +import com.intellij.psi.PsiClassType import com.intellij.psi.PsiField import org.jetbrains.uast.UBinaryExpression import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UClassLiteralExpression import org.jetbrains.uast.UExpression import org.jetbrains.uast.UFile import org.jetbrains.uast.ULambdaExpression @@ -18,6 +20,7 @@ private const val FRAMEWORK_PACKAGE_PREFIX = "me.devnatan.inventoryframework" private const val ON_CLICK_METHOD = "onClick" private const val AVAILABLE_SLOT_METHOD = "availableSlot" private val ROW_COLUMN_FACTORY_METHODS = setOf("row", "firstRow", "lastRow", "column", "firstColumn", "lastColumn") +private val OPEN_VIEW_METHODS = setOf("openForPlayer", "openForEveryone") class ClickActionExtractionResult( val indexed: Map, @@ -111,6 +114,9 @@ object ClickHandlerExtractor { ): PreviewClickAction { val statement = singleBodyExpression(lambda.body) ?: return PreviewClickAction.Unsupported val call = asCallExpression(statement) ?: return PreviewClickAction.Unsupported + + matchOpenViewAction(call)?.let { return it } + val receiver = call.receiver?.skipParenthesizedExprDown() as? UReferenceExpression ?: return PreviewClickAction.Unsupported val field = receiver.resolve() as? PsiField ?: return PreviewClickAction.Unsupported @@ -172,6 +178,21 @@ object ClickHandlerExtractor { return null } + // `click.openForPlayer(OtherView.class)` / `click.openForEveryone(OtherView.class)` - unlike + // the state-mutating shapes above, the receiver here is the click context itself rather than a + // tracked state field, so it's matched independently before that field-resolution path runs. + private fun matchOpenViewAction(call: UCallExpression): PreviewClickAction.OpenView? { + if (call.methodName !in OPEN_VIEW_METHODS) return null + val method = call.resolve() ?: return null + val declaringClass = method.containingClass?.qualifiedName ?: return null + if (!declaringClass.startsWith(FRAMEWORK_PACKAGE_PREFIX)) return null + val classLiteral = call.valueArguments.getOrNull(0)?.skipParenthesizedExprDown() as? UClassLiteralExpression + ?: return null + val targetClass = (classLiteral.type as? PsiClassType)?.resolve() ?: return null + val fqn = targetClass.qualifiedName ?: return null + return PreviewClickAction.OpenView(fqn) + } + private fun isGetCallOn(expr: UExpression, field: PsiField): Boolean { val call = asCallExpression(expr) ?: return false if (call.methodName != "get") return false diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ForLoopAnalyzer.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ForLoopAnalyzer.kt new file mode 100644 index 00000000..638974be --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ForLoopAnalyzer.kt @@ -0,0 +1,77 @@ +package me.devnatan.inventoryframework.intellij + +import org.jetbrains.uast.UBinaryExpression +import org.jetbrains.uast.UDeclarationsExpression +import org.jetbrains.uast.UExpression +import org.jetbrains.uast.UForExpression +import org.jetbrains.uast.UPostfixExpression +import org.jetbrains.uast.UPrefixExpression +import org.jetbrains.uast.UReferenceExpression +import org.jetbrains.uast.UVariable +import org.jetbrains.uast.UastBinaryOperator +import org.jetbrains.uast.UastPostfixOperator +import org.jetbrains.uast.UastPrefixOperator +import org.jetbrains.uast.skipParenthesizedExprDown + +// The counter variable of a simple bounded counting for-loop, plus the actual sequence of values +// it takes across every statically-known iteration (e.g. [1, 2, 3, 4, 5] for +// `for (int i = 1; i <= 5; i++)`) - not just how many there are. AvailableSlotResolver only needs +// the count (how many slots a call site inside the loop claims); ItemExtractor needs the values +// themselves, for the narrower case where the loop counter is read directly as an item's amount. +internal class LoopIteration(val variable: UVariable, val values: List) + +// Mirrors what a real Java for-loop actually does, but only for the canonical counting shape - +// variable on the left of the condition, stepped by a plain i++/i--/++i/--i. Anything else (the +// bound on the left, a `+= step`/`i = i + n` update, a non-literal bound, a for-each/while loop) +// returns null; callers fall back to treating the loop as unanalyzable. +internal object ForLoopAnalyzer { + + fun analyze(forExpr: UForExpression): LoopIteration? { + val variable = (forExpr.declaration as? UDeclarationsExpression) + ?.declarations?.singleOrNull() as? UVariable ?: return null + val start = variable.uastInitializer?.evaluate() as? Int ?: return null + + val step = when (val update = forExpr.update?.skipParenthesizedExprDown()) { + is UPostfixExpression -> stepOf(update.operator, update.operand, variable) ?: return null + is UPrefixExpression -> stepOf(update.operator, update.operand, variable) ?: return null + else -> return null + } + + val condition = forExpr.condition?.skipParenthesizedExprDown() as? UBinaryExpression ?: return null + if (!isReferenceTo(condition.leftOperand, variable)) return null + val bound = condition.rightOperand.skipParenthesizedExprDown().evaluate() as? Int ?: return null + + val count = when { + step == 1 && condition.operator == UastBinaryOperator.LESS -> bound - start + step == 1 && condition.operator == UastBinaryOperator.LESS_OR_EQUALS -> bound - start + 1 + step == -1 && condition.operator == UastBinaryOperator.GREATER -> start - bound + step == -1 && condition.operator == UastBinaryOperator.GREATER_OR_EQUALS -> start - bound + 1 + else -> return null + }.coerceAtLeast(0) + + return LoopIteration(variable, List(count) { start + it * step }) + } + + private fun stepOf(operator: UastPostfixOperator, operand: UExpression, variable: UVariable): Int? { + if (!isReferenceTo(operand, variable)) return null + return when (operator) { + UastPostfixOperator.INC -> 1 + UastPostfixOperator.DEC -> -1 + else -> null + } + } + + private fun stepOf(operator: UastPrefixOperator, operand: UExpression, variable: UVariable): Int? { + if (!isReferenceTo(operand, variable)) return null + return when (operator) { + UastPrefixOperator.INC -> 1 + UastPrefixOperator.DEC -> -1 + else -> null + } + } + + private fun isReferenceTo(expr: UExpression, variable: UVariable): Boolean { + val ref = expr.skipParenthesizedExprDown() as? UReferenceExpression ?: return false + return ref.resolve() == variable.sourcePsi + } +} diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewFileEditor.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewFileEditor.kt index 57b1b4a7..57df2d19 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewFileEditor.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewFileEditor.kt @@ -7,12 +7,14 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.application.ReadAction import com.intellij.openapi.editor.Inlay import com.intellij.openapi.editor.ScrollType import com.intellij.openapi.editor.event.CaretEvent import com.intellij.openapi.editor.event.CaretListener import com.intellij.openapi.fileEditor.FileEditor import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.fileEditor.TextEditor import com.intellij.openapi.project.DumbService import com.intellij.openapi.project.Project @@ -22,9 +24,12 @@ import com.intellij.openapi.ui.popup.JBPopupFactory import com.intellij.openapi.util.TextRange import com.intellij.openapi.util.UserDataHolderBase import com.intellij.openapi.vfs.VirtualFile +import com.intellij.pom.Navigatable +import com.intellij.psi.JavaPsiFacade import com.intellij.psi.PsiManager import com.intellij.psi.PsiTreeChangeAdapter import com.intellij.psi.PsiTreeChangeEvent +import com.intellij.psi.search.GlobalSearchScope import com.intellij.ui.awt.RelativePoint import com.intellij.ui.components.JBScrollPane import com.intellij.util.Alarm @@ -43,6 +48,8 @@ private const val REFRESH_DEBOUNCE_MILLIS = 300 private const val TOOLBAR_PLACE = "InventoryFramework.PreviewToolbar" private const val COPY_FEEDBACK_FADEOUT_MILLIS = 1500 +private class ResolvedViewClass(val targetFile: VirtualFile?, val navigatable: Navigatable?) + private class ImageTransferable(private val image: Image) : Transferable { override fun getTransferDataFlavors(): Array = arrayOf(DataFlavor.imageFlavor) @@ -69,8 +76,16 @@ class InventoryPreviewFileEditor( private val stateInlays = mutableListOf>() private val rootComponent: JComponent by lazy { buildComponent() } + // Set when this file's preview was opened by simulating an "open view" click from another + // view's preview - lets Undo fall back to "go back to that view" once there's no more local + // interaction state left to undo. See PreviewNavigationHistory. + private var backNavigationFile: VirtualFile? = null + init { panel.onSlotClicked = ::onSlotClicked + val navigationHistory = PreviewNavigationHistory.getInstance(project) + navigationHistory.register(file, this) + navigationHistory.consumePendingArrival(file)?.let(::onArrivedViaInteractiveNavigation) // The editor can be reconstructed (e.g. restoring last-open tabs on startup) while the // project is still indexing; retry once smart mode is reached instead of caching a // permanent extraction failure from that race. @@ -156,6 +171,7 @@ class InventoryPreviewFileEditor( when (val action = model.clickActions[index]) { null -> return PreviewClickAction.Unsupported -> showUnsupportedInteractionBalloon() + is PreviewClickAction.OpenView -> navigateToViewClass(action.targetClassFqn) else -> { interactionState.apply(action) panel.setModel(interactionState.resolve(model)) @@ -165,11 +181,55 @@ class InventoryPreviewFileEditor( } } + // Simulating an actual view switch would mean building and rendering an entirely separate + // preview model, so the closest useful stand-in for "this click opens another view" is + // jumping straight to that view's source, mirroring what the click would do at runtime. + private fun navigateToViewClass(targetClassFqn: String) { + // findClass/navigationElement/containingFile touch the PSI/stub index, which asserts read + // access even from the EDT - the mouse-click callback that reaches here doesn't hold one + // implicitly. + val resolved = ReadAction.compute { + val psiClass = JavaPsiFacade.getInstance(project).findClass(targetClassFqn, GlobalSearchScope.allScope(project)) + ResolvedViewClass(psiClass?.containingFile?.virtualFile, psiClass?.navigationElement as? Navigatable) + } + val navigatable = resolved.navigatable + if (navigatable == null) { + showViewNotFoundBalloon(targetClassFqn) + return + } + // Recorded before navigating (rather than from the destination editor's init) since the + // target's tab may already be open, in which case no init ever runs for this jump. + resolved.targetFile?.let { PreviewNavigationHistory.getInstance(project).recordOpenViewNavigation(file, it) } + navigatable.navigate(true) + } + + // Called by PreviewNavigationHistory, either synchronously from navigateToViewClass (target + // tab already open) or from this editor's own init (target tab just now being created) - + // either way, arriving here via a simulated "open view" click should carry interactive mode + // forward and let Undo hop back once there's nothing local left to undo. + fun onArrivedViaInteractiveNavigation(fromFile: VirtualFile) { + backNavigationFile = fromFile + if (interactiveModeEnabled) return + interactiveModeEnabled = true + panel.interactiveMode = true + refreshStateHints() + } + + private fun showViewNotFoundBalloon(targetClassFqn: String) { + val simpleName = targetClassFqn.substringAfterLast('.') + JBPopupFactory.getInstance() + .createHtmlTextBalloonBuilder("Could not find view class $simpleName", MessageType.WARNING, null) + .setFadeoutTime(COPY_FEEDBACK_FADEOUT_MILLIS.toLong()) + .createBalloon() + .show(RelativePoint.getCenterOf(panel), Balloon.Position.above) + } + private fun showSimulatedActionBalloon(action: PreviewClickAction) { val (stateId, description) = when (action) { is PreviewClickAction.ToggleBoolean -> action.stateId to "toggled" is PreviewClickAction.Delta -> action.stateId to "changed by ${if (action.delta >= 0) "+" else ""}${action.delta}" is PreviewClickAction.SetLiteral -> action.stateId to "set to ${action.value}" + is PreviewClickAction.OpenView -> return PreviewClickAction.Unsupported -> return } val fieldName = stateId.substringAfterLast('#') @@ -180,18 +240,32 @@ class InventoryPreviewFileEditor( .show(RelativePoint.getCenterOf(panel), Balloon.Position.above) } + // Called whenever interactive mode is toggled (on or off) - either direction starts a fresh + // interactive session, so the "came from" link left over from a previous session's navigation + // shouldn't carry over into this one. private fun resetInteraction() { interactionState.reset(currentModel) currentModel?.let { panel.setModel(interactionState.resolve(it)) } + backNavigationFile = null refreshStateHints() } + // Local state takes priority: only once there's nothing left to undo in this view does Undo + // fall back to "go back to the view whose click sent us here", chaining a multi-hop navigation + // (A opens B opens C) back one step at a time rather than jumping straight to A from C. private fun undoLastInteraction() { - val model = currentModel ?: return - if (interactionState.undo()) { + val model = currentModel + if (model != null && interactionState.undo()) { panel.setModel(interactionState.resolve(model)) refreshStateHints() + return } + backNavigationFile?.let(::navigateBackToFile) + } + + private fun navigateBackToFile(target: VirtualFile) { + if (!target.isValid) return + OpenFileDescriptor(project, target).navigate(true) } private fun showUnsupportedInteractionBalloon() { @@ -264,6 +338,7 @@ class InventoryPreviewFileEditor( override fun isSelected(e: AnActionEvent) = interactiveModeEnabled override fun setSelected(e: AnActionEvent, state: Boolean) { interactiveModeEnabled = state + panel.interactiveMode = state resetInteraction() } override fun update(e: AnActionEvent) { @@ -277,7 +352,7 @@ class InventoryPreviewFileEditor( group.add(object : AnAction("Undo Last Interaction", "Revert the last simulated click", AllIcons.Actions.Undo) { override fun actionPerformed(e: AnActionEvent) = undoLastInteraction() override fun update(e: AnActionEvent) { - e.presentation.isEnabled = interactiveModeEnabled && interactionState.canUndo() + e.presentation.isEnabled = interactiveModeEnabled && (interactionState.canUndo() || backNavigationFile != null) } override fun getActionUpdateThread() = ActionUpdateThread.EDT }) @@ -319,6 +394,7 @@ class InventoryPreviewFileEditor( override fun getFile(): VirtualFile = file override fun dispose() { + PreviewNavigationHistory.getInstance(project).unregister(file, this) stateInlays.forEach { it.dispose() } stateInlays.clear() } diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewPanel.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewPanel.kt index 99acc54e..7bfa39f5 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewPanel.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewPanel.kt @@ -2,6 +2,7 @@ package me.devnatan.inventoryframework.intellij import com.intellij.ui.JBColor import java.awt.Color +import java.awt.Cursor import java.awt.Dimension import java.awt.Font import java.awt.Graphics @@ -10,6 +11,7 @@ import java.awt.Point import java.awt.RenderingHints import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter import java.awt.geom.AffineTransform import java.awt.geom.Point2D import java.awt.image.BufferedImage @@ -59,6 +61,10 @@ private val DEFAULT_ZOOM_INDEX = ZOOM_LEVELS.indexOf(1.0) private val SLOT_NUMBER_BACKGROUND = Color(0, 0, 0, 170) +// Vanilla lightens a slot under the cursor with a translucent white overlay rather than a border, +// so a hovered slot reads as "about to be interacted with" the same way it does in-game. +private val SLOT_HOVER_OVERLAY = Color(255, 255, 255, 80) + private val chestSprites: Map by lazy { (1..6).associateWith { rows -> InventoryPreviewPanel::class.java.getResourceAsStream("/assets/sprites/chest-$rows.png")?.use(ImageIO::read) @@ -91,11 +97,23 @@ class InventoryPreviewPanel : JPanel() { private var model: PreviewModel? = null private var highlightedSlotIndices: Set = emptySet() + private var hoveredSlotIndex: Int? = null private var zoomIndex = DEFAULT_ZOOM_INDEX private val zoom: Double get() = ZOOM_LEVELS[zoomIndex] var onSlotClicked: ((Int) -> Unit)? = null + // Whether the preview simulates click handlers instead of navigating to source. Gates both the + // hover-lighten effect and the pointer cursor below - outside interactive mode a click just + // navigates to source, so there's nothing being "hovered for interaction" to indicate. + var interactiveMode: Boolean = false + set(value) { + if (field == value) return + field = value + refreshHoverCursor() + repaint() + } + var showSlotNumbers: Boolean = false set(value) { if (field == value) return @@ -118,7 +136,35 @@ class InventoryPreviewPanel : JPanel() { val index = slotIndexAt(currentModel, logicalPoint) ?: return onSlotClicked?.invoke(index) } + + override fun mouseExited(e: MouseEvent) = updateHoveredSlot(null) }) + addMouseMotionListener(object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent) { + val currentModel = model + if (currentModel == null) { + updateHoveredSlot(null) + return + } + val logicalPoint = Point((e.x / zoom).toInt(), (e.y / zoom).toInt()) + updateHoveredSlot(slotIndexAt(currentModel, logicalPoint)) + } + }) + } + + private fun updateHoveredSlot(index: Int?) { + if (hoveredSlotIndex == index) return + hoveredSlotIndex = index + refreshHoverCursor() + repaint() + } + + // A slot only gets the pointer cursor in interactive mode, and only when it actually has a + // click handler to simulate - otherwise the cursor stays the default arrow, same as clicking + // a slot with nothing bound to it silently does nothing. + private fun refreshHoverCursor() { + val isClickable = interactiveMode && hoveredSlotIndex?.let { model?.clickActions?.containsKey(it) } == true + cursor = if (isClickable) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor() } fun setModel(newModel: PreviewModel?) { @@ -402,6 +448,11 @@ class InventoryPreviewPanel : JPanel() { } stackCountLabel?.let { paintStackCount(g, it, x, y, size) } + if (interactiveMode && index == hoveredSlotIndex) { + g.color = SLOT_HOVER_OVERLAY + g.fillRect(x + 1, y + 1, size - 2, size - 2) + } + if (index in highlightedSlotIndices) { g.color = JBColor.BLUE g.drawRect(x, y, size - 1, size - 1) diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewInteractionState.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewInteractionState.kt index d32fc2f4..c9967507 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewInteractionState.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewInteractionState.kt @@ -54,6 +54,9 @@ class PreviewInteractionState { values[action.stateId] = action.value true } + // Navigation is handled by the caller before apply() is ever reached for this action - + // it doesn't touch simulated state, so there's nothing to undo. + is PreviewClickAction.OpenView -> false PreviewClickAction.Unsupported -> false } if (changed) history.addLast(beforeChange) diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewModel.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewModel.kt index 32162fec..8700789d 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewModel.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewModel.kt @@ -59,6 +59,7 @@ sealed class PreviewClickAction { data class ToggleBoolean(val stateId: String) : PreviewClickAction() data class Delta(val stateId: String, val delta: Int) : PreviewClickAction() data class SetLiteral(val stateId: String, val value: Any) : PreviewClickAction() + data class OpenView(val targetClassFqn: String) : PreviewClickAction() object Unsupported : PreviewClickAction() } diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewNavigationHistory.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewNavigationHistory.kt new file mode 100644 index 00000000..f2794d37 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewNavigationHistory.kt @@ -0,0 +1,47 @@ +package me.devnatan.inventoryframework.intellij + +import com.intellij.openapi.components.Service +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile + +// Bridges the interactive-preview "click to open another view" simulation across the file editors +// involved, since each view's preview lives in its own InventoryPreviewFileEditor instance with no +// direct reference to the others. Two things need to survive the jump from one file's editor to +// another's: interactive mode staying on, and a way for "Undo" in the destination to mean "go back +// to where I came from" once there's no more local state to undo. +@Service(Service.Level.PROJECT) +class PreviewNavigationHistory { + + private val activeEditors = mutableMapOf() + + // Target file -> source file, for a navigation recorded before the target's editor exists yet + // (e.g. the view being opened has no tab open at all). Consumed once the editor for that file + // is actually constructed. + private val pendingArrivals = mutableMapOf() + + fun register(file: VirtualFile, editor: InventoryPreviewFileEditor) { + activeEditors[file] = editor + } + + fun unregister(file: VirtualFile, editor: InventoryPreviewFileEditor) { + if (activeEditors[file] === editor) activeEditors.remove(file) + } + + // Called just before simulating an "open view" click navigates away from `source` to `target`. + // If `target`'s editor is already alive (an existing tab being reused), it's told directly; + // otherwise the navigation is stashed for that editor to pick up once it's created. + fun recordOpenViewNavigation(source: VirtualFile, target: VirtualFile) { + val existing = activeEditors[target] + if (existing != null) { + existing.onArrivedViaInteractiveNavigation(source) + } else { + pendingArrivals[target] = source + } + } + + fun consumePendingArrival(target: VirtualFile): VirtualFile? = pendingArrivals.remove(target) + + companion object { + fun getInstance(project: Project): PreviewNavigationHistory = project.getService(PreviewNavigationHistory::class.java) + } +} diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/SlotTargetResolver.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/SlotTargetResolver.kt index e37aeb19..6c57d1a1 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/SlotTargetResolver.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/SlotTargetResolver.kt @@ -32,12 +32,21 @@ internal object SlotTargetResolver { private fun resolveDirect(call: UCallExpression, rows: Int, columns: Int): SlotTarget? { val args = call.valueArguments return when (call.methodName) { + // The 2-arg shape is ambiguous between two overloads that only differ by the second + // parameter's type: slot(int row, int column) and the Bukkit slot(int slot, ItemStack + // item) sugar. evaluate() only folds compile-time constants, so a genuine row/column + // pair resolves both to Int; an item argument never does, which is what tells the two + // apart here - falling back to treating the first argument as a raw slot index. "slot" -> when (args.size) { 1 -> (args[0].evaluate() as? Int)?.let { SlotTarget.Indices(listOf(it)) } 2 -> { - val row = args[0].evaluate() as? Int ?: return null - val column = args[1].evaluate() as? Int ?: return null - slotIndex(row, column, rows, columns)?.let { SlotTarget.Indices(listOf(it)) } + val first = args[0].evaluate() as? Int ?: return null + val column = args[1].evaluate() as? Int + if (column != null) { + slotIndex(first, column, rows, columns)?.let { SlotTarget.Indices(listOf(it)) } + } else { + SlotTarget.Indices(listOf(first)) + } } else -> null }