From 7c8af8207c3ea129e865f1d62493ac585784444e Mon Sep 17 00:00:00 2001 From: Natan Date: Mon, 3 Aug 2026 11:00:23 -0300 Subject: [PATCH] Simulate state and click handlers in IntelliJ inventory preview Lets developers toggle Interactive Preview mode and click slots to simulate their onClick handlers (boolean toggles, int deltas, literal sets), with inline hints showing each state field's current simulated value and undo/reset support. Extraction is split out into dedicated StateExtractor/ClickHandlerExtractor/SlotTargetResolver/LambdaSearch helpers so item and click-action resolution can share state lookups. --- .../runtime/view/RowColumnSample.java | 8 +- .../intellij/ClickHandlerExtractor.kt | 161 ++++++++++++++ .../intellij/InventoryPreviewFileEditor.kt | 118 +++++++++- .../intellij/InventoryPreviewPanel.kt | 6 +- .../intellij/ItemExtractor.kt | 206 +++++++++++------- .../intellij/LambdaSearch.kt | 23 ++ .../intellij/PreviewInteractionState.kt | 74 +++++++ .../intellij/PreviewModel.kt | 49 +++++ .../intellij/SlotTargetResolver.kt | 77 +++++++ .../intellij/StateExtractor.kt | 59 +++++ .../intellij/StateValueInlayRenderer.kt | 34 +++ .../intellij/UastSupport.kt | 29 +++ .../intellij/ViewExtractor.kt | 29 ++- 13 files changed, 772 insertions(+), 101 deletions(-) create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ClickHandlerExtractor.kt create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/LambdaSearch.kt create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewInteractionState.kt create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/SlotTargetResolver.kt create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/StateExtractor.kt create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/StateValueInlayRenderer.kt create mode 100644 intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/UastSupport.kt diff --git a/examples/paper/src/main/java/me/devnatan/inventoryframework/runtime/view/RowColumnSample.java b/examples/paper/src/main/java/me/devnatan/inventoryframework/runtime/view/RowColumnSample.java index 53cbd102..5701c8db 100644 --- a/examples/paper/src/main/java/me/devnatan/inventoryframework/runtime/view/RowColumnSample.java +++ b/examples/paper/src/main/java/me/devnatan/inventoryframework/runtime/view/RowColumnSample.java @@ -3,12 +3,16 @@ import me.devnatan.inventoryframework.View; import me.devnatan.inventoryframework.ViewConfigBuilder; import me.devnatan.inventoryframework.context.RenderContext; +import me.devnatan.inventoryframework.state.MutableIntState; +import me.devnatan.inventoryframework.state.MutableState; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; public class RowColumnSample extends View { + private final MutableIntState intState = mutableState(0); + @Override public void onInit(@NotNull ViewConfigBuilder config) { config.cancelOnClick().title("Row & Column").size(6); @@ -16,10 +20,12 @@ public void onInit(@NotNull ViewConfigBuilder config) { @Override public void onFirstRender(@NotNull RenderContext render) { - render.slot(13, new ItemStack(Material.DIAMOND_SWORD)); + render.slot(13) + .withItem(intState.get(render) == 3 ? new ItemStack(Material.GOLD_INGOT) : new ItemStack(Material.IRON_INGOT)); render.firstRow((pos, slot) -> slot.withItem(new ItemStack(Material.BLUE_STAINED_GLASS_PANE)) + .onClick(click -> intState.increment(click)) ); render.lastColumn((pos, slot) -> 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 new file mode 100644 index 00000000..b6a1bda9 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ClickHandlerExtractor.kt @@ -0,0 +1,161 @@ +package me.devnatan.inventoryframework.intellij + +import com.intellij.psi.PsiField +import org.jetbrains.uast.UBinaryExpression +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UExpression +import org.jetbrains.uast.UFile +import org.jetbrains.uast.ULambdaExpression +import org.jetbrains.uast.ULiteralExpression +import org.jetbrains.uast.UReferenceExpression +import org.jetbrains.uast.UUnaryExpression +import org.jetbrains.uast.UastBinaryOperator +import org.jetbrains.uast.UastPrefixOperator +import org.jetbrains.uast.skipParenthesizedExprDown +import org.jetbrains.uast.visitor.AbstractUastVisitor + +private const val FRAMEWORK_PACKAGE_PREFIX = "me.devnatan.inventoryframework" +private const val ON_CLICK_METHOD = "onClick" +private val ROW_COLUMN_FACTORY_METHODS = setOf("row", "firstRow", "lastRow", "column", "firstColumn", "lastColumn") + +class ClickActionExtractionResult(val indexed: Map, val layoutBound: Map) + +// Finds `.onClick(handler)` bindings, both the direct chain form ItemExtractor also resolves for +// withItem (`slot(i).onClick(...)`, possibly with a `.withItem(...)` in between) and the row/column +// factory-lambda form (`render.firstRow((pos, slot) -> slot.withItem(x).onClick(y))`), then +// pattern-matches the handler body against a small, fixed vocabulary (matchClickAction below). +// Anything outside that vocabulary is recorded as Unsupported rather than ignored, so the preview +// can say "can't simulate this" instead of silently doing nothing when clicked. +object ClickHandlerExtractor { + + fun extract( + uFile: UFile, + rows: Int, + columns: Int, + stateIndex: Map, + ): ClickActionExtractionResult { + val indexed = mutableMapOf() + val layoutBound = mutableMapOf() + + fun apply(target: SlotTarget, action: PreviewClickAction) { + when (target) { + is SlotTarget.Indices -> target.slots.forEach { indexed[it] = action } + is SlotTarget.Layout -> layoutBound[target.character] = action + } + } + + uFile.accept(object : AbstractUastVisitor() { + override fun visitCallExpression(node: UCallExpression): Boolean { + val method = node.resolve() ?: return false + val declaringClass = method.containingClass?.qualifiedName + if (declaringClass == null || !declaringClass.startsWith(FRAMEWORK_PACKAGE_PREFIX)) return false + val methodName = node.methodName + + if (methodName == ON_CLICK_METHOD && node.valueArguments.size == 1) { + val receiverCall = asCallExpression(node.receiver) ?: return false + val target = SlotTargetResolver.resolve(receiverCall, rows, columns) ?: return false + val lambda = node.valueArguments[0].skipParenthesizedExprDown() as? ULambdaExpression ?: return false + apply(target, matchClickAction(lambda, stateIndex)) + return false + } + + if (methodName in ROW_COLUMN_FACTORY_METHODS) { + resolveFactoryClickAction(node, rows, columns, stateIndex)?.let { (target, action) -> + apply(target, action) + } + return false + } + + return false + } + }) + + return ClickActionExtractionResult(indexed, layoutBound) + } + + private fun resolveFactoryClickAction( + node: UCallExpression, + rows: Int, + columns: Int, + stateIndex: Map, + ): Pair? { + val (target, lambda) = SlotTargetResolver.resolveFactoryLambda(node, rows, columns) ?: return null + val handlerExpr = findCallArgumentInLambda(lambda, ON_CLICK_METHOD) ?: return null + val handlerLambda = handlerExpr.skipParenthesizedExprDown() as? ULambdaExpression ?: return null + return target to matchClickAction(handlerLambda, stateIndex) + } + + private fun matchClickAction( + lambda: ULambdaExpression, + stateIndex: Map, + ): PreviewClickAction { + val statement = singleBodyExpression(lambda.body) ?: return PreviewClickAction.Unsupported + val call = asCallExpression(statement) ?: return PreviewClickAction.Unsupported + val receiver = call.receiver?.skipParenthesizedExprDown() as? UReferenceExpression + ?: return PreviewClickAction.Unsupported + val field = receiver.resolve() as? PsiField ?: return PreviewClickAction.Unsupported + val declaration = stateIndex[field] ?: return PreviewClickAction.Unsupported + + return when (call.methodName) { + "set" -> { + val value = call.valueArguments.getOrNull(0)?.skipParenthesizedExprDown() + ?: return PreviewClickAction.Unsupported + matchSetValue(value, field, declaration) ?: PreviewClickAction.Unsupported + } + // MutableIntState.increment/decrement(host) - the idiomatic alternative to + // set(get(ctx) + 1, ctx), which is why both are matched independently. + "increment" -> if (declaration.kind == PreviewStateKind.INT) { + PreviewClickAction.Delta(declaration.id, 1) + } else { + PreviewClickAction.Unsupported + } + "decrement" -> if (declaration.kind == PreviewStateKind.INT) { + PreviewClickAction.Delta(declaration.id, -1) + } else { + PreviewClickAction.Unsupported + } + else -> PreviewClickAction.Unsupported + } + } + + private fun matchSetValue(value: UExpression, field: PsiField, declaration: PreviewStateDeclaration): PreviewClickAction? { + val literal = value as? ULiteralExpression + if (literal != null) { + return when (declaration.kind) { + PreviewStateKind.BOOLEAN -> (literal.value as? Boolean)?.let { PreviewClickAction.SetLiteral(declaration.id, it) } + PreviewStateKind.INT -> (literal.value as? Int)?.let { PreviewClickAction.SetLiteral(declaration.id, it) } + } + } + + if (value is UUnaryExpression && value.operator == UastPrefixOperator.LOGICAL_NOT) { + if (declaration.kind != PreviewStateKind.BOOLEAN) return null + return if (isGetCallOn(value.operand.skipParenthesizedExprDown(), field)) { + PreviewClickAction.ToggleBoolean(declaration.id) + } else { + null + } + } + + if (value is UBinaryExpression) { + if (declaration.kind != PreviewStateKind.INT) return null + val left = value.leftOperand.skipParenthesizedExprDown() + val right = value.rightOperand.skipParenthesizedExprDown() as? ULiteralExpression + val delta = right?.value as? Int ?: return null + if (!isGetCallOn(left, field)) return null + return when (value.operator) { + UastBinaryOperator.PLUS -> PreviewClickAction.Delta(declaration.id, delta) + UastBinaryOperator.MINUS -> PreviewClickAction.Delta(declaration.id, -delta) + else -> null + } + } + + return null + } + + private fun isGetCallOn(expr: UExpression, field: PsiField): Boolean { + val call = asCallExpression(expr) ?: return false + if (call.methodName != "get") return false + val receiver = call.receiver?.skipParenthesizedExprDown() as? UReferenceExpression ?: return false + return receiver.resolve() == field + } +} 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 8f34b8e5..57b1b4a7 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,6 +7,7 @@ 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.editor.Inlay import com.intellij.openapi.editor.ScrollType import com.intellij.openapi.editor.event.CaretEvent import com.intellij.openapi.editor.event.CaretListener @@ -63,10 +64,13 @@ class InventoryPreviewFileEditor( private val propertyChangeSupport = PropertyChangeSupport(this) private val refreshAlarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this) private var currentModel: PreviewModel? = null + private val interactionState = PreviewInteractionState() + private var interactiveModeEnabled = false + private val stateInlays = mutableListOf>() private val rootComponent: JComponent by lazy { buildComponent() } init { - panel.onSlotClicked = ::navigateToRange + panel.onSlotClicked = ::onSlotClicked // 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. @@ -99,8 +103,27 @@ class InventoryPreviewFileEditor( null } currentModel = model - panel.setModel(model) + interactionState.reset(model) + panel.setModel(model?.let(interactionState::resolve)) updateHighlightForCaret() + refreshStateHints() + } + + // Shows the current simulated value next to each state field's declaration, e.g. + // `mutableState(0); → 3`, while interactive mode is on. Rebuilt wholesale on every state + // change rather than incrementally updated in place - cheap given how few state fields a + // view typically has, and avoids tracking which inlay belongs to which declaration. + private fun refreshStateHints() { + stateInlays.forEach { it.dispose() } + stateInlays.clear() + if (!interactiveModeEnabled) return + val model = currentModel ?: return + val editor = textEditor.editor + for (declaration in model.states) { + val value = interactionState.currentValue(declaration.id) ?: continue + val renderer = StateValueInlayRenderer("Current value: $value", declaration.declarationOffset) + editor.inlayModel.addBlockElement(declaration.declarationOffset, false, true, 0, renderer)?.let { stateInlays += it } + } } private fun updateHighlightForCaret() { @@ -120,6 +143,65 @@ class InventoryPreviewFileEditor( editor.contentComponent.requestFocusInWindow() } + private fun onSlotClicked(index: Int) { + val model = currentModel ?: return + if (interactiveModeEnabled) { + simulateClick(model, index) + } else { + model.slots[index]?.sourceRange?.let(::navigateToRange) + } + } + + private fun simulateClick(model: PreviewModel, index: Int) { + when (val action = model.clickActions[index]) { + null -> return + PreviewClickAction.Unsupported -> showUnsupportedInteractionBalloon() + else -> { + interactionState.apply(action) + panel.setModel(interactionState.resolve(model)) + refreshStateHints() + showSimulatedActionBalloon(action) + } + } + } + + 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}" + PreviewClickAction.Unsupported -> return + } + val fieldName = stateId.substringAfterLast('#') + JBPopupFactory.getInstance() + .createHtmlTextBalloonBuilder("$fieldName $description", MessageType.INFO, null) + .setFadeoutTime(COPY_FEEDBACK_FADEOUT_MILLIS.toLong()) + .createBalloon() + .show(RelativePoint.getCenterOf(panel), Balloon.Position.above) + } + + private fun resetInteraction() { + interactionState.reset(currentModel) + currentModel?.let { panel.setModel(interactionState.resolve(it)) } + refreshStateHints() + } + + private fun undoLastInteraction() { + val model = currentModel ?: return + if (interactionState.undo()) { + panel.setModel(interactionState.resolve(model)) + refreshStateHints() + } + } + + private fun showUnsupportedInteractionBalloon() { + JBPopupFactory.getInstance() + .createHtmlTextBalloonBuilder("This click handler can't be simulated in the fast preview", MessageType.WARNING, null) + .setFadeoutTime(COPY_FEEDBACK_FADEOUT_MILLIS.toLong()) + .createBalloon() + .show(RelativePoint.getCenterOf(panel), Balloon.Position.above) + } + private fun buildComponent(): JComponent { val toolbar = ActionManager.getInstance().createActionToolbar(TOOLBAR_PLACE, createToolbarActions(), true) val wrapper = JPanel(BorderLayout()) @@ -172,6 +254,33 @@ class InventoryPreviewFileEditor( } override fun getActionUpdateThread() = ActionUpdateThread.EDT }) + group.addSeparator() + group.add( + object : ToggleAction( + "Interactive Preview", + "Click slots to simulate their click handlers instead of navigating to source", + AllIcons.Actions.Execute, + ) { + override fun isSelected(e: AnActionEvent) = interactiveModeEnabled + override fun setSelected(e: AnActionEvent, state: Boolean) { + interactiveModeEnabled = state + resetInteraction() + } + override fun update(e: AnActionEvent) { + super.update(e) + e.presentation.icon = if (interactiveModeEnabled) AllIcons.Actions.Suspend else AllIcons.Actions.Execute + e.presentation.text = if (interactiveModeEnabled) "Stop Interactive Preview" else "Start Interactive Preview" + } + override fun getActionUpdateThread() = ActionUpdateThread.EDT + }, + ) + 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() + } + override fun getActionUpdateThread() = ActionUpdateThread.EDT + }) return group } @@ -209,5 +318,8 @@ class InventoryPreviewFileEditor( override fun getFile(): VirtualFile = file - override fun dispose() {} + override fun dispose() { + 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 1bbcb047..a048b4df 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 @@ -1,6 +1,5 @@ package me.devnatan.inventoryframework.intellij -import com.intellij.openapi.util.TextRange import com.intellij.ui.JBColor import java.awt.Color import java.awt.Dimension @@ -75,7 +74,7 @@ class InventoryPreviewPanel : JPanel() { private var zoomIndex = DEFAULT_ZOOM_INDEX private val zoom: Double get() = ZOOM_LEVELS[zoomIndex] - var onSlotClicked: ((TextRange) -> Unit)? = null + var onSlotClicked: ((Int) -> Unit)? = null var showSlotNumbers: Boolean = false set(value) { @@ -97,8 +96,7 @@ class InventoryPreviewPanel : JPanel() { val currentModel = model ?: return val logicalPoint = Point((e.x / zoom).toInt(), (e.y / zoom).toInt()) val index = slotIndexAt(currentModel, logicalPoint) ?: return - val range = currentModel.slots[index]?.sourceRange ?: return - onSlotClicked?.invoke(range) + onSlotClicked?.invoke(index) } }) } diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemExtractor.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemExtractor.kt index 768cef19..812e7d6a 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemExtractor.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ItemExtractor.kt @@ -1,15 +1,20 @@ package me.devnatan.inventoryframework.intellij +import com.intellij.openapi.util.TextRange import com.intellij.psi.PsiClass import com.intellij.psi.PsiField +import org.jetbrains.uast.UBinaryExpression import org.jetbrains.uast.UCallExpression import org.jetbrains.uast.UExpression import org.jetbrains.uast.UFile -import org.jetbrains.uast.ULambdaExpression +import org.jetbrains.uast.UIfExpression import org.jetbrains.uast.ULiteralExpression import org.jetbrains.uast.UReferenceExpression +import org.jetbrains.uast.UUnaryExpression import org.jetbrains.uast.UVariable +import org.jetbrains.uast.UastBinaryOperator import org.jetbrains.uast.UastCallKind +import org.jetbrains.uast.UastPrefixOperator import org.jetbrains.uast.skipParenthesizedExprDown import org.jetbrains.uast.toUElementOfType import org.jetbrains.uast.visitor.AbstractUastVisitor @@ -19,19 +24,31 @@ private const val MATERIAL_FQN = "org.bukkit.Material" private const val FRAMEWORK_PACKAGE_PREFIX = "me.devnatan.inventoryframework" private val ITEM_BINDING_METHODS = setOf("withItem", "renderWith", "onRender") private val ROW_COLUMN_FACTORY_METHODS = setOf("row", "firstRow", "lastRow", "column", "firstColumn", "lastColumn") - -private sealed class SlotTarget { - data class Indices(val slots: List) : SlotTarget() - data class Layout(val character: Char) : SlotTarget() -} - -class ItemExtractionResult(val indexedSlots: Map, val layoutBindings: Map) +// Java's `==`/`!=` map to IDENTITY_EQUALS/IDENTITY_NOT_EQUALS in UAST (not EQUALS/NOT_EQUALS, +// despite both rendering as "==" in asRenderString() - EQUALS is Kotlin's structural `==`). +// Support both so this also works if the framework is ever used from Kotlin view classes. +private val NOT_EQUALS_OPERATORS = setOf(UastBinaryOperator.NOT_EQUALS, UastBinaryOperator.IDENTITY_NOT_EQUALS) +private val EQUALITY_OPERATORS = setOf(UastBinaryOperator.EQUALS, UastBinaryOperator.IDENTITY_EQUALS) + NOT_EQUALS_OPERATORS + +class ItemExtractionResult( + val indexedSlots: Map, + val layoutBindings: Map, + val indexedConditionalItems: Map, + val layoutConditionalItems: Map, +) object ItemExtractor { - fun extract(uFile: UFile, rows: Int, columns: Int): ItemExtractionResult { + fun extract( + uFile: UFile, + rows: Int, + columns: Int, + stateIndex: Map, + ): ItemExtractionResult { val indexedSlots = mutableMapOf() val layoutBindings = mutableMapOf() + val indexedConditionalItems = mutableMapOf() + val layoutConditionalItems = mutableMapOf() fun apply(target: SlotTarget, slot: PreviewSlot?) { if (slot == null) return @@ -41,6 +58,13 @@ object ItemExtractor { } } + fun applyConditional(target: SlotTarget, item: ConditionalItem) { + when (target) { + is SlotTarget.Indices -> target.slots.forEach { indexedConditionalItems[it] = item } + is SlotTarget.Layout -> layoutConditionalItems[target.character] = item + } + } + uFile.accept(object : AbstractUastVisitor() { override fun visitCallExpression(node: UCallExpression): Boolean { val method = node.resolve() ?: return false @@ -50,14 +74,19 @@ object ItemExtractor { val methodName = node.methodName val range = node.sourcePsi?.textRange if (methodName in ITEM_BINDING_METHODS && node.valueArguments.size == 1) { - val receiverCall = node.receiver?.skipParenthesizedExprDown() as? UCallExpression ?: return false - val target = resolveChainTarget(receiverCall, rows, columns) ?: return false - val slot = if (methodName == "withItem") { - resolveItem(node.valueArguments[0])?.copy(sourceRange = range) + val receiverCall = asCallExpression(node.receiver) ?: return false + val target = SlotTargetResolver.resolve(receiverCall, rows, columns) ?: return false + if (methodName == "withItem") { + val conditional = resolveConditionalItem(node.valueArguments[0], stateIndex, range) + if (conditional != null) { + applyConditional(target, conditional) + apply(target, conditional.default) + } else { + apply(target, resolveItem(node.valueArguments[0])?.copy(sourceRange = range)) + } } else { - PreviewSlot(null, dynamic = true, sourceRange = range) + apply(target, PreviewSlot(null, dynamic = true, sourceRange = range)) } - apply(target, slot) return false } @@ -75,83 +104,20 @@ object ItemExtractor { } }) - return ItemExtractionResult(indexedSlots, layoutBindings) - } - - private fun resolveChainTarget(call: UCallExpression, rows: Int, columns: Int): SlotTarget? { - val args = call.valueArguments - return when (call.methodName) { - "slot" -> (args.getOrNull(0)?.evaluate() as? Int)?.let { SlotTarget.Indices(listOf(it)) } - "firstSlot" -> SlotTarget.Indices(listOf(0)) - "lastSlot" -> SlotTarget.Indices(listOf(rows * columns - 1)) - "layoutSlot" -> (args.getOrNull(0)?.evaluate() as? Char)?.let { SlotTarget.Layout(it) } - // row()/column() and their first/last sugar actually return the "next available slot in - // that row/column" - not the whole row - but the idiomatic usage (see RowColumnSample) is - // to call them in a loop to fill the entire row/column, which we can't detect from a single - // call site without loop analysis. Treating each call as "fill the whole row/column" is - // wrong for genuine single-slot usage but renders the common case correctly instead of - // showing nothing at all. - "row" -> (args.getOrNull(0)?.evaluate() as? Int)?.let { rowIndices(it, rows, columns) } - "firstRow" -> rowIndices(1, rows, columns) - "lastRow" -> rowIndices(rows, rows, columns) - "column" -> (args.getOrNull(0)?.evaluate() as? Int)?.let { columnIndices(it, rows, columns) } - "firstColumn" -> columnIndices(1, rows, columns) - "lastColumn" -> columnIndices(columns, rows, columns) - else -> null - } - } - - private fun rowIndices(row1Indexed: Int, rows: Int, columns: Int): SlotTarget.Indices? { - if (row1Indexed !in 1..rows) return null - val row0 = row1Indexed - 1 - return SlotTarget.Indices((0 until columns).map { row0 * columns + it }) - } - - private fun columnIndices(column1Indexed: Int, rows: Int, columns: Int): SlotTarget.Indices? { - if (column1Indexed !in 1..columns) return null - val column0 = column1Indexed - 1 - return SlotTarget.Indices((0 until rows).map { it * columns + column0 }) + return ItemExtractionResult(indexedSlots, layoutBindings, indexedConditionalItems, layoutConditionalItems) } // row/column/firstRow/lastRow/firstColumn/lastColumn also have a `(BiConsumer factory)` // overload - e.g. `render.firstRow((pos, slot) -> slot.withItem(item))` - where withItem is called // on the lambda's builder parameter rather than chained directly onto the row/column call. Same - // "fill the whole row/column" heuristic as resolveChainTarget, just with the item found by + // "fill the whole row/column" heuristic as SlotTargetResolver, just with the item found by // searching the lambda body instead of a receiver chain. private fun resolveFactoryCall(node: UCallExpression, rows: Int, columns: Int): Pair? { - val args = node.valueArguments - val (target, lambdaArg) = when (node.methodName) { - "row" -> if (args.size == 2) { - (args[0].evaluate() as? Int)?.let { rowIndices(it, rows, columns) }?.let { it to args[1] } - } else null - "firstRow" -> if (args.size == 1) rowIndices(1, rows, columns)?.let { it to args[0] } else null - "lastRow" -> if (args.size == 1) rowIndices(rows, rows, columns)?.let { it to args[0] } else null - "column" -> if (args.size == 2) { - (args[0].evaluate() as? Int)?.let { columnIndices(it, rows, columns) }?.let { it to args[1] } - } else null - "firstColumn" -> if (args.size == 1) columnIndices(1, rows, columns)?.let { it to args[0] } else null - "lastColumn" -> if (args.size == 1) columnIndices(columns, rows, columns)?.let { it to args[0] } else null - else -> null - } ?: return null - - val lambda = lambdaArg.skipParenthesizedExprDown() as? ULambdaExpression ?: return null - val itemExpr = findWithItemArgumentInLambda(lambda) ?: return null + val (target, lambda) = SlotTargetResolver.resolveFactoryLambda(node, rows, columns) ?: return null + val itemExpr = findCallArgumentInLambda(lambda, "withItem") ?: return null return target to itemExpr } - private fun findWithItemArgumentInLambda(lambda: ULambdaExpression): UExpression? { - var found: UExpression? = null - lambda.body.accept(object : AbstractUastVisitor() { - override fun visitCallExpression(node: UCallExpression): Boolean { - if (found == null && node.methodName == "withItem" && node.valueArguments.size == 1) { - found = node.valueArguments[0] - } - return found != null - } - }) - return found - } - private fun resolveDirectItemCall(node: UCallExpression, rows: Int, columns: Int): Pair? { val args = node.valueArguments return when (node.methodName) { @@ -169,6 +135,82 @@ object ItemExtractor { } } + // Detects `withItem(field.get(ctx) ? thenItem : elseItem)` / `withItem(!field.get(ctx) ? ... : ...)` + // where `field` is a known boolean mutableState. Anything else (non-boolean state, a condition + // that isn't a direct get() read, branches that aren't plain ItemStack constructors) isn't matched - + // the slot just falls back to whatever resolveItem() makes of it (usually "dynamic"). + private fun resolveConditionalItem( + rawExpr: UExpression?, + stateIndex: Map, + range: TextRange?, + ): ConditionalItem? { + val expr = rawExpr?.skipParenthesizedExprDown() as? UIfExpression ?: return null + if (!expr.isTernary) return null + val match = resolveCondition(expr.condition, stateIndex) ?: return null + val thenSlot = resolveItem(expr.thenExpression)?.copy(sourceRange = range) ?: return null + val elseSlot = resolveItem(expr.elseExpression)?.copy(sourceRange = range) ?: return null + val holds = match.condition.evaluate(match.declaration.initialValue) ?: return null + val default = if (holds) thenSlot else elseSlot + return ConditionalItem(match.condition, thenSlot, elseSlot, default) + } + + private class ConditionMatch(val condition: PreviewCondition, val declaration: PreviewStateDeclaration) + + // Recognizes `field.get(ctx)` / `!field.get(ctx)` for a boolean state, and + // `field.get(ctx) == literal` / `!=` (either operand order) for an int state. Anything else - + // arbitrary boolean expressions, comparisons against non-literals, non-tracked fields - isn't + // matched, and the item stays whatever resolveItem() alone makes of it. + private fun resolveCondition( + rawCondition: UExpression, + stateIndex: Map, + ): ConditionMatch? { + val condition = rawCondition.skipParenthesizedExprDown() + + if (condition is UUnaryExpression && condition.operator == UastPrefixOperator.LOGICAL_NOT) { + val inner = resolveCondition(condition.operand, stateIndex) ?: return null + return ConditionMatch(negate(inner.condition), inner.declaration) + } + + if (condition is UBinaryExpression && condition.operator in EQUALITY_OPERATORS) { + val left = condition.leftOperand.skipParenthesizedExprDown() + val right = condition.rightOperand.skipParenthesizedExprDown() + val (field, literalExpr) = fieldAndLiteral(left, right, stateIndex) ?: return null + val declaration = stateIndex.getValue(field) + if (declaration.kind != PreviewStateKind.INT) return null + val literalValue = (literalExpr as? ULiteralExpression)?.value as? Int ?: return null + val negated = condition.operator in NOT_EQUALS_OPERATORS + return ConditionMatch(PreviewCondition.IntEquals(declaration.id, literalValue, negated), declaration) + } + + val field = resolveGetCallField(condition, stateIndex) ?: return null + val declaration = stateIndex.getValue(field) + if (declaration.kind != PreviewStateKind.BOOLEAN) return null + return ConditionMatch(PreviewCondition.BooleanState(declaration.id, negated = false), declaration) + } + + private fun negate(condition: PreviewCondition): PreviewCondition = when (condition) { + is PreviewCondition.BooleanState -> condition.copy(negated = !condition.negated) + is PreviewCondition.IntEquals -> condition.copy(negated = !condition.negated) + } + + private fun fieldAndLiteral( + left: UExpression, + right: UExpression, + stateIndex: Map, + ): Pair? { + resolveGetCallField(left, stateIndex)?.let { return it to right } + resolveGetCallField(right, stateIndex)?.let { return it to left } + return null + } + + private fun resolveGetCallField(expr: UExpression, stateIndex: Map): PsiField? { + val call = asCallExpression(expr) ?: return null + if (call.methodName != "get") return null + val receiver = call.receiver?.skipParenthesizedExprDown() as? UReferenceExpression ?: return null + val field = receiver.resolve() as? PsiField ?: return null + return field.takeIf { it in stateIndex } + } + private fun resolveItem(rawExpr: UExpression?): PreviewSlot? { if (rawExpr == null) return null val expr = rawExpr.skipParenthesizedExprDown() diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/LambdaSearch.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/LambdaSearch.kt new file mode 100644 index 00000000..9cf97898 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/LambdaSearch.kt @@ -0,0 +1,23 @@ +package me.devnatan.inventoryframework.intellij + +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UExpression +import org.jetbrains.uast.ULambdaExpression +import org.jetbrains.uast.visitor.AbstractUastVisitor + +// Row/column factory forms (e.g. `render.firstRow((pos, slot) -> slot.withItem(x).onClick(y))`) bind +// withItem/onClick on the lambda's own builder parameter rather than a resolvable slot(...) chain, so +// both ItemExtractor and ClickHandlerExtractor need to search the lambda body for a specific call by +// name instead of walking a receiver chain. +internal fun findCallArgumentInLambda(lambda: ULambdaExpression, methodName: String): UExpression? { + var found: UExpression? = null + lambda.body.accept(object : AbstractUastVisitor() { + override fun visitCallExpression(node: UCallExpression): Boolean { + if (found == null && node.methodName == methodName && node.valueArguments.size == 1) { + found = node.valueArguments[0] + } + return found != null + } + }) + return found +} 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 new file mode 100644 index 00000000..d32fc2f4 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/PreviewInteractionState.kt @@ -0,0 +1,74 @@ +package me.devnatan.inventoryframework.intellij + +// Owns the "fast preview" simulated state values for interactive mode. This is deliberately not +// part of PreviewModel itself - the model is rebuilt from source on every edit (see ViewExtractor), +// while this survives across edits until explicitly reset, so clicking around doesn't get wiped +// out by unrelated typing elsewhere in the file. +class PreviewInteractionState { + + private val values = mutableMapOf() + private val history = ArrayDeque>() + + fun reset(model: PreviewModel?) { + values.clear() + history.clear() + model?.states?.forEach { values[it.id] = it.initialValue } + } + + fun canUndo(): Boolean = history.isNotEmpty() + + fun currentValue(stateId: String): Any? = values[stateId] + + // Reverts the most recent apply() one step at a time, like undo - not back to the initial + // state. Returns true if there was something to undo. + fun undo(): Boolean { + val previous = history.removeLastOrNull() ?: return false + values.clear() + values.putAll(previous) + return true + } + + // Returns true if the action was one of the recognized/simulatable kinds. + fun apply(action: PreviewClickAction): Boolean { + val beforeChange = values.toMap() + val changed = when (action) { + is PreviewClickAction.ToggleBoolean -> { + val current = values[action.stateId] as? Boolean + if (current == null) { + false + } else { + values[action.stateId] = !current + true + } + } + is PreviewClickAction.Delta -> { + val current = values[action.stateId] as? Int + if (current == null) { + false + } else { + values[action.stateId] = current + action.delta + true + } + } + is PreviewClickAction.SetLiteral -> { + values[action.stateId] = action.value + true + } + PreviewClickAction.Unsupported -> false + } + if (changed) history.addLast(beforeChange) + return changed + } + + // Overlays the current simulated state onto the model's default-resolved slots, for every + // slot whose item is conditioned on a tracked state field. + fun resolve(model: PreviewModel): PreviewModel { + if (model.conditionalItems.isEmpty()) return model + val overridden = model.slots.toMutableMap() + for ((index, conditional) in model.conditionalItems) { + val holds = conditional.condition.evaluate(values[conditional.condition.stateId]) ?: continue + overridden[index] = if (holds) conditional.whenTrue else conditional.whenFalse + } + return model.copy(slots = overridden) + } +} 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 b8aac672..5d9638ff 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 @@ -4,6 +4,52 @@ import com.intellij.openapi.util.TextRange data class PreviewSlot(val material: String?, val dynamic: Boolean, val sourceRange: TextRange? = null) +enum class PreviewStateKind { BOOLEAN, INT } + +data class PreviewStateDeclaration( + val id: String, + val kind: PreviewStateKind, + val initialValue: Any, + val declarationOffset: Int, +) + +// A recognized shape for a withItem(...) ternary's condition. BooleanState is a direct (optionally +// negated) read of a boolean state; IntEquals is `intField.get(ctx) == N` (or `!=`), which is the +// common way an int/selection state picks between items (e.g. "is this the selected page"). +sealed class PreviewCondition { + abstract val stateId: String + + data class BooleanState(override val stateId: String, val negated: Boolean) : PreviewCondition() + data class IntEquals(override val stateId: String, val value: Int, val negated: Boolean) : PreviewCondition() + + // Null if `current` isn't the type this condition expects (e.g. state hasn't been resolved yet). + fun evaluate(current: Any?): Boolean? = when (this) { + is BooleanState -> (current as? Boolean)?.let { it != negated } + is IntEquals -> (current as? Int)?.let { (it == value) != negated } + } +} + +// A withItem(...) argument that's a ternary conditioned on a known state field, e.g. +// `withItem(enabled.get(ctx) ? onItem : offItem)` or `withItem(page.get(ctx) == 1 ? A : B)`. +// `default` is whichever branch the state's initial value picks, i.e. what's shown before any +// interaction happens. +data class ConditionalItem( + val condition: PreviewCondition, + val whenTrue: PreviewSlot, + val whenFalse: PreviewSlot, + val default: PreviewSlot, +) + +// What a slot's onClick handler does to preview state, as far as the fast/offline preview can +// tell statically. Unsupported means a handler exists but its body isn't one of the recognized +// shapes - clicking it should say so rather than silently doing nothing or guessing. +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() + object Unsupported : PreviewClickAction() +} + data class PreviewModel( val viewTypeName: String, val rows: Int, @@ -12,4 +58,7 @@ data class PreviewModel( val title: String?, val layout: List?, val slots: Map = emptyMap(), + val states: List = emptyList(), + val conditionalItems: Map = emptyMap(), + val clickActions: Map = emptyMap(), ) 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 new file mode 100644 index 00000000..27e99e68 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/SlotTargetResolver.kt @@ -0,0 +1,77 @@ +package me.devnatan.inventoryframework.intellij + +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.ULambdaExpression +import org.jetbrains.uast.skipParenthesizedExprDown + +internal sealed class SlotTarget { + data class Indices(val slots: List) : SlotTarget() + data class Layout(val character: Char) : SlotTarget() +} + +// Shared by ItemExtractor (withItem/renderWith/onRender) and ClickHandlerExtractor (onClick): +// both bind to the same slot(...)/layoutSlot(...)/row()/column() receiver chain, so the target +// resolution logic must stay identical between the two or their slot mappings could drift apart. +internal object SlotTargetResolver { + + // Walks up the receiver chain past any builder calls that don't define a target themselves + // (withItem, onClick, cancelOnClick, ...) until it finds slot(...)/layoutSlot(...)/row()/column(). + fun resolve(call: UCallExpression, rows: Int, columns: Int): SlotTarget? { + resolveDirect(call, rows, columns)?.let { return it } + val receiverCall = asCallExpression(call.receiver) ?: return null + return resolve(receiverCall, rows, columns) + } + + private fun resolveDirect(call: UCallExpression, rows: Int, columns: Int): SlotTarget? { + val args = call.valueArguments + return when (call.methodName) { + "slot" -> (args.getOrNull(0)?.evaluate() as? Int)?.let { SlotTarget.Indices(listOf(it)) } + "firstSlot" -> SlotTarget.Indices(listOf(0)) + "lastSlot" -> SlotTarget.Indices(listOf(rows * columns - 1)) + "layoutSlot" -> (args.getOrNull(0)?.evaluate() as? Char)?.let { SlotTarget.Layout(it) } + "row" -> (args.getOrNull(0)?.evaluate() as? Int)?.let { rowIndices(it, rows, columns) } + "firstRow" -> rowIndices(1, rows, columns) + "lastRow" -> rowIndices(rows, rows, columns) + "column" -> (args.getOrNull(0)?.evaluate() as? Int)?.let { columnIndices(it, rows, columns) } + "firstColumn" -> columnIndices(1, rows, columns) + "lastColumn" -> columnIndices(columns, rows, columns) + else -> null + } + } + + fun rowIndices(row1Indexed: Int, rows: Int, columns: Int): SlotTarget.Indices? { + if (row1Indexed !in 1..rows) return null + val row0 = row1Indexed - 1 + return SlotTarget.Indices((0 until columns).map { row0 * columns + it }) + } + + fun columnIndices(column1Indexed: Int, rows: Int, columns: Int): SlotTarget.Indices? { + if (column1Indexed !in 1..columns) return null + val column0 = column1Indexed - 1 + return SlotTarget.Indices((0 until rows).map { it * columns + column0 }) + } + + // For row/column factory forms: `render.firstRow((pos, slot) -> ...)`. Resolves the row/column's + // target indices and returns the lambda whose body needs to be searched for the actual binding + // (withItem/onClick), since it operates on the lambda's own builder parameter rather than a + // resolvable slot(...) chain. + fun resolveFactoryLambda(node: UCallExpression, rows: Int, columns: Int): Pair? { + val args = node.valueArguments + val (target, lambdaArg) = when (node.methodName) { + "row" -> if (args.size == 2) { + (args[0].evaluate() as? Int)?.let { rowIndices(it, rows, columns) }?.let { it to args[1] } + } else null + "firstRow" -> if (args.size == 1) rowIndices(1, rows, columns)?.let { it to args[0] } else null + "lastRow" -> if (args.size == 1) rowIndices(rows, rows, columns)?.let { it to args[0] } else null + "column" -> if (args.size == 2) { + (args[0].evaluate() as? Int)?.let { columnIndices(it, rows, columns) }?.let { it to args[1] } + } else null + "firstColumn" -> if (args.size == 1) columnIndices(1, rows, columns)?.let { it to args[0] } else null + "lastColumn" -> if (args.size == 1) columnIndices(columns, rows, columns)?.let { it to args[0] } else null + else -> null + } ?: return null + + val lambda = lambdaArg.skipParenthesizedExprDown() as? ULambdaExpression ?: return null + return target to lambda + } +} diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/StateExtractor.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/StateExtractor.kt new file mode 100644 index 00000000..29ac41f3 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/StateExtractor.kt @@ -0,0 +1,59 @@ +package me.devnatan.inventoryframework.intellij + +import com.intellij.psi.PsiField +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UField +import org.jetbrains.uast.UFile +import org.jetbrains.uast.ULiteralExpression +import org.jetbrains.uast.skipParenthesizedExprDown + +// mutableState/mutableIntState are declared on StateAccess but resolve through whatever concrete +// class in the framework hierarchy the call is actually made against (e.g. PlatformView) - same +// reason ItemExtractor/ClickHandlerExtractor match on a package prefix rather than one exact FQN. +private const val FRAMEWORK_PACKAGE_PREFIX = "me.devnatan.inventoryframework" +private val MUTABLE_STATE_METHODS = setOf("mutableState", "mutableIntState") + +class StateExtractionResult( + val declarations: List, + val index: Map, +) + +// Only fields initialized with a boolean or int *literal* are tracked - that covers both +// mutableIntState(0) and the generic mutableState(0)/mutableState(false) (kind is inferred from +// the literal, not the method name, since `MutableState x = mutableState(0)` is just as +// common as mutableIntState in practice). computedState and non-literal initial values are left +// out rather than guessed - these are also the only kinds ClickHandlerExtractor knows how to match. +object StateExtractor { + + fun extract(uFile: UFile): StateExtractionResult { + val declarations = mutableListOf() + val index = mutableMapOf() + + for (uClass in uFile.classes) { + for (field in uClass.uastDeclarations.filterIsInstance()) { + val psiField = field.sourcePsi as? PsiField ?: continue + val initializer = field.uastInitializer?.skipParenthesizedExprDown() as? UCallExpression ?: continue + if (initializer.methodName !in MUTABLE_STATE_METHODS) continue + val method = initializer.resolve() ?: continue + val declaringClass = method.containingClass?.qualifiedName + if (declaringClass == null || !declaringClass.startsWith(FRAMEWORK_PACKAGE_PREFIX)) continue + + val literalValue = + (initializer.valueArguments.getOrNull(0)?.skipParenthesizedExprDown() as? ULiteralExpression)?.value + val kind = when (literalValue) { + is Boolean -> PreviewStateKind.BOOLEAN + is Int -> PreviewStateKind.INT + else -> continue + } + + val declaration = PreviewStateDeclaration(stateId(psiField), kind, literalValue, psiField.textRange.startOffset) + declarations += declaration + index[psiField] = declaration + } + } + + return StateExtractionResult(declarations, index) + } + + private fun stateId(field: PsiField): String = "${field.containingClass?.qualifiedName}#${field.name}" +} diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/StateValueInlayRenderer.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/StateValueInlayRenderer.kt new file mode 100644 index 00000000..1c982f4e --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/StateValueInlayRenderer.kt @@ -0,0 +1,34 @@ +package me.devnatan.inventoryframework.intellij + +import com.intellij.openapi.editor.EditorCustomElementRenderer +import com.intellij.openapi.editor.Inlay +import com.intellij.openapi.editor.colors.EditorFontType +import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.ui.JBColor +import java.awt.Font +import java.awt.FontMetrics +import java.awt.Graphics +import java.awt.Rectangle + +// Renders the current interactive-preview value as a block line above a state field's +// declaration. Only meaningful while interactive mode is on - see +// InventoryPreviewFileEditor.refreshStateHints. `offset` is the field declaration's start offset, +// used to align the text with the code's actual indentation (tabs/spaces alike) rather than +// starting at column 0, since block elements don't auto-indent themselves. +internal class StateValueInlayRenderer(private val text: String, private val offset: Int) : EditorCustomElementRenderer { + + override fun calcWidthInPixels(inlay: Inlay<*>): Int = indentX(inlay) + metrics(inlay).stringWidth(text) + 6 + + override fun paint(inlay: Inlay<*>, g: Graphics, targetRegion: Rectangle, textAttributes: TextAttributes) { + val fm = metrics(inlay) + g.color = JBColor.GRAY + g.font = font(inlay) + g.drawString(text, targetRegion.x + indentX(inlay), targetRegion.y + fm.ascent + (targetRegion.height - fm.height) / 2) + } + + private fun indentX(inlay: Inlay<*>): Int = inlay.editor.offsetToXY(offset).x + + private fun font(inlay: Inlay<*>): Font = inlay.editor.colorsScheme.getFont(EditorFontType.PLAIN).deriveFont(Font.ITALIC) + + private fun metrics(inlay: Inlay<*>): FontMetrics = inlay.editor.contentComponent.getFontMetrics(font(inlay)) +} diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/UastSupport.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/UastSupport.kt new file mode 100644 index 00000000..1cbd99b8 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/UastSupport.kt @@ -0,0 +1,29 @@ +package me.devnatan.inventoryframework.intellij + +import org.jetbrains.uast.UBlockExpression +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UExpression +import org.jetbrains.uast.UQualifiedReferenceExpression +import org.jetbrains.uast.UReturnExpression +import org.jetbrains.uast.skipParenthesizedExprDown + +// Java's UAST bridge wraps any explicitly-qualified call (`a.b(...)`) as a +// UQualifiedReferenceExpression (receiver + selector) wherever it appears as a *value* - a method +// argument, a binary operand, a lambda return, etc. - rather than exposing the UCallExpression +// directly. Only unqualified calls (`foo()`, `new Foo()`) come through as a plain UCallExpression. +// Every place that tries to interpret an arbitrary UExpression as "is this a call" needs this. +internal fun asCallExpression(expr: UExpression?): UCallExpression? { + return when (val e = expr?.skipParenthesizedExprDown()) { + is UCallExpression -> e + is UQualifiedReferenceExpression -> e.selector.skipParenthesizedExprDown() as? UCallExpression + else -> null + } +} + +// A single-expression lambda body (`x -> foo()`) is represented as an implicit block containing an +// implicit return of the expression, same shape as a block body whose only statement is `return +// foo();`. Unwrap both so callers can treat the two forms identically. +internal fun singleBodyExpression(body: UExpression): UExpression? { + val statement = (body as? UBlockExpression)?.expressions?.singleOrNull() ?: body + return (statement as? UReturnExpression)?.returnExpression ?: statement +} diff --git a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ViewExtractor.kt b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ViewExtractor.kt index 252775be..72e6224d 100644 --- a/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ViewExtractor.kt +++ b/intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/ViewExtractor.kt @@ -57,8 +57,14 @@ object ViewExtractor { val columns = geometry.columns val maxSize = minOf(geometry.maxSize, rows * columns) - val items = ItemExtractor.extract(uFile, rows, columns) - val slots = items.indexedSlots + layoutBoundSlots(layout, items.layoutBindings, columns) + val states = StateExtractor.extract(uFile) + val items = ItemExtractor.extract(uFile, rows, columns, states.index) + val slots = items.indexedSlots + layoutBoundValues(layout, items.layoutBindings, columns) + val conditionalItems = + items.indexedConditionalItems + layoutBoundValues(layout, items.layoutConditionalItems, columns) + val clickActionResult = ClickHandlerExtractor.extract(uFile, rows, columns, states.index) + val clickActions = + clickActionResult.indexed + layoutBoundValues(layout, clickActionResult.layoutBound, columns) return PreviewModel( viewTypeName = viewTypeFieldName ?: DEFAULT_VIEW_TYPE_NAME, @@ -68,22 +74,23 @@ object ViewExtractor { title = title, layout = layout, slots = slots, + states = states.declarations, + conditionalItems = conditionalItems, + clickActions = clickActions, ) } - private fun layoutBoundSlots( - layout: List?, - layoutBindings: Map, - columns: Int, - ): Map { - if (layout == null || layoutBindings.isEmpty()) return emptyMap() - val slots = mutableMapOf() + // Shared by items, conditional items and click actions - all three are keyed by layout + // character and need to be flattened onto the same row/column grid the same way. + private fun layoutBoundValues(layout: List?, bindings: Map, columns: Int): Map { + if (layout == null || bindings.isEmpty()) return emptyMap() + val values = mutableMapOf() layout.forEachIndexed { row, rowChars -> rowChars.forEachIndexed { col, character -> - layoutBindings[character]?.let { slots[row * columns + col] = it } + bindings[character]?.let { values[row * columns + col] = it } } } - return slots + return values } private fun resolveViewTypeFieldName(arg: UExpression?): String? {