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? {