Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Int> {
val anchors = mutableListOf<Int>()
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<UForExpression>(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<Int>,
occupiedSlots: Set<Int>,
layout: List<String>?,
columns: Int,
maxSize: Int,
): Map<Int, List<Int>> {
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<Int, MutableList<Int>>()
anchors.forEachIndexed { i, anchor ->
candidates.getOrNull(i)?.let { resolved.getOrPut(anchor) { mutableListOf() }.add(it) }
}
return resolved
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Int, PreviewClickAction>,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int>)

// 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<DataFlavor> = arrayOf(DataFlavor.imageFlavor)

Expand All @@ -69,8 +76,16 @@ class InventoryPreviewFileEditor(
private val stateInlays = mutableListOf<Inlay<*>>()
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.
Expand Down Expand Up @@ -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))
Expand All @@ -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<ResolvedViewClass, Throwable> {
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('#')
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand All @@ -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
})
Expand Down Expand Up @@ -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()
}
Expand Down
Loading
Loading