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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,29 @@
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);
}

@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) ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int, PreviewClickAction>, val layoutBound: Map<Char, PreviewClickAction>)

// 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<PsiField, PreviewStateDeclaration>,
): ClickActionExtractionResult {
val indexed = mutableMapOf<Int, PreviewClickAction>()
val layoutBound = mutableMapOf<Char, PreviewClickAction>()

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<PsiField, PreviewStateDeclaration>,
): Pair<SlotTarget, PreviewClickAction>? {
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<PsiField, PreviewStateDeclaration>,
): 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Inlay<*>>()
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.
Expand Down Expand Up @@ -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() {
Expand All @@ -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())
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -209,5 +318,8 @@ class InventoryPreviewFileEditor(

override fun getFile(): VirtualFile = file

override fun dispose() {}
override fun dispose() {
stateInlays.forEach { it.dispose() }
stateInlays.clear()
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
})
}
Expand Down
Loading
Loading