Skip to content

Commit 0ba6a84

Browse files
authored
Interactive state simulation in the IntelliJ preview (#844)
1 parent c564e98 commit 0ba6a84

13 files changed

Lines changed: 772 additions & 101 deletions

File tree

examples/paper/src/main/java/me/devnatan/inventoryframework/runtime/view/RowColumnSample.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,29 @@
33
import me.devnatan.inventoryframework.View;
44
import me.devnatan.inventoryframework.ViewConfigBuilder;
55
import me.devnatan.inventoryframework.context.RenderContext;
6+
import me.devnatan.inventoryframework.state.MutableIntState;
7+
import me.devnatan.inventoryframework.state.MutableState;
68
import org.bukkit.Material;
79
import org.bukkit.inventory.ItemStack;
810
import org.jetbrains.annotations.NotNull;
911

1012
public class RowColumnSample extends View {
1113

14+
private final MutableIntState intState = mutableState(0);
15+
1216
@Override
1317
public void onInit(@NotNull ViewConfigBuilder config) {
1418
config.cancelOnClick().title("Row & Column").size(6);
1519
}
1620

1721
@Override
1822
public void onFirstRender(@NotNull RenderContext render) {
19-
render.slot(13, new ItemStack(Material.DIAMOND_SWORD));
23+
render.slot(13)
24+
.withItem(intState.get(render) == 3 ? new ItemStack(Material.GOLD_INGOT) : new ItemStack(Material.IRON_INGOT));
2025

2126
render.firstRow((pos, slot) ->
2227
slot.withItem(new ItemStack(Material.BLUE_STAINED_GLASS_PANE))
28+
.onClick(click -> intState.increment(click))
2329
);
2430

2531
render.lastColumn((pos, slot) ->
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package me.devnatan.inventoryframework.intellij
2+
3+
import com.intellij.psi.PsiField
4+
import org.jetbrains.uast.UBinaryExpression
5+
import org.jetbrains.uast.UCallExpression
6+
import org.jetbrains.uast.UExpression
7+
import org.jetbrains.uast.UFile
8+
import org.jetbrains.uast.ULambdaExpression
9+
import org.jetbrains.uast.ULiteralExpression
10+
import org.jetbrains.uast.UReferenceExpression
11+
import org.jetbrains.uast.UUnaryExpression
12+
import org.jetbrains.uast.UastBinaryOperator
13+
import org.jetbrains.uast.UastPrefixOperator
14+
import org.jetbrains.uast.skipParenthesizedExprDown
15+
import org.jetbrains.uast.visitor.AbstractUastVisitor
16+
17+
private const val FRAMEWORK_PACKAGE_PREFIX = "me.devnatan.inventoryframework"
18+
private const val ON_CLICK_METHOD = "onClick"
19+
private val ROW_COLUMN_FACTORY_METHODS = setOf("row", "firstRow", "lastRow", "column", "firstColumn", "lastColumn")
20+
21+
class ClickActionExtractionResult(val indexed: Map<Int, PreviewClickAction>, val layoutBound: Map<Char, PreviewClickAction>)
22+
23+
// Finds `.onClick(handler)` bindings, both the direct chain form ItemExtractor also resolves for
24+
// withItem (`slot(i).onClick(...)`, possibly with a `.withItem(...)` in between) and the row/column
25+
// factory-lambda form (`render.firstRow((pos, slot) -> slot.withItem(x).onClick(y))`), then
26+
// pattern-matches the handler body against a small, fixed vocabulary (matchClickAction below).
27+
// Anything outside that vocabulary is recorded as Unsupported rather than ignored, so the preview
28+
// can say "can't simulate this" instead of silently doing nothing when clicked.
29+
object ClickHandlerExtractor {
30+
31+
fun extract(
32+
uFile: UFile,
33+
rows: Int,
34+
columns: Int,
35+
stateIndex: Map<PsiField, PreviewStateDeclaration>,
36+
): ClickActionExtractionResult {
37+
val indexed = mutableMapOf<Int, PreviewClickAction>()
38+
val layoutBound = mutableMapOf<Char, PreviewClickAction>()
39+
40+
fun apply(target: SlotTarget, action: PreviewClickAction) {
41+
when (target) {
42+
is SlotTarget.Indices -> target.slots.forEach { indexed[it] = action }
43+
is SlotTarget.Layout -> layoutBound[target.character] = action
44+
}
45+
}
46+
47+
uFile.accept(object : AbstractUastVisitor() {
48+
override fun visitCallExpression(node: UCallExpression): Boolean {
49+
val method = node.resolve() ?: return false
50+
val declaringClass = method.containingClass?.qualifiedName
51+
if (declaringClass == null || !declaringClass.startsWith(FRAMEWORK_PACKAGE_PREFIX)) return false
52+
val methodName = node.methodName
53+
54+
if (methodName == ON_CLICK_METHOD && node.valueArguments.size == 1) {
55+
val receiverCall = asCallExpression(node.receiver) ?: return false
56+
val target = SlotTargetResolver.resolve(receiverCall, rows, columns) ?: return false
57+
val lambda = node.valueArguments[0].skipParenthesizedExprDown() as? ULambdaExpression ?: return false
58+
apply(target, matchClickAction(lambda, stateIndex))
59+
return false
60+
}
61+
62+
if (methodName in ROW_COLUMN_FACTORY_METHODS) {
63+
resolveFactoryClickAction(node, rows, columns, stateIndex)?.let { (target, action) ->
64+
apply(target, action)
65+
}
66+
return false
67+
}
68+
69+
return false
70+
}
71+
})
72+
73+
return ClickActionExtractionResult(indexed, layoutBound)
74+
}
75+
76+
private fun resolveFactoryClickAction(
77+
node: UCallExpression,
78+
rows: Int,
79+
columns: Int,
80+
stateIndex: Map<PsiField, PreviewStateDeclaration>,
81+
): Pair<SlotTarget, PreviewClickAction>? {
82+
val (target, lambda) = SlotTargetResolver.resolveFactoryLambda(node, rows, columns) ?: return null
83+
val handlerExpr = findCallArgumentInLambda(lambda, ON_CLICK_METHOD) ?: return null
84+
val handlerLambda = handlerExpr.skipParenthesizedExprDown() as? ULambdaExpression ?: return null
85+
return target to matchClickAction(handlerLambda, stateIndex)
86+
}
87+
88+
private fun matchClickAction(
89+
lambda: ULambdaExpression,
90+
stateIndex: Map<PsiField, PreviewStateDeclaration>,
91+
): PreviewClickAction {
92+
val statement = singleBodyExpression(lambda.body) ?: return PreviewClickAction.Unsupported
93+
val call = asCallExpression(statement) ?: return PreviewClickAction.Unsupported
94+
val receiver = call.receiver?.skipParenthesizedExprDown() as? UReferenceExpression
95+
?: return PreviewClickAction.Unsupported
96+
val field = receiver.resolve() as? PsiField ?: return PreviewClickAction.Unsupported
97+
val declaration = stateIndex[field] ?: return PreviewClickAction.Unsupported
98+
99+
return when (call.methodName) {
100+
"set" -> {
101+
val value = call.valueArguments.getOrNull(0)?.skipParenthesizedExprDown()
102+
?: return PreviewClickAction.Unsupported
103+
matchSetValue(value, field, declaration) ?: PreviewClickAction.Unsupported
104+
}
105+
// MutableIntState.increment/decrement(host) - the idiomatic alternative to
106+
// set(get(ctx) + 1, ctx), which is why both are matched independently.
107+
"increment" -> if (declaration.kind == PreviewStateKind.INT) {
108+
PreviewClickAction.Delta(declaration.id, 1)
109+
} else {
110+
PreviewClickAction.Unsupported
111+
}
112+
"decrement" -> if (declaration.kind == PreviewStateKind.INT) {
113+
PreviewClickAction.Delta(declaration.id, -1)
114+
} else {
115+
PreviewClickAction.Unsupported
116+
}
117+
else -> PreviewClickAction.Unsupported
118+
}
119+
}
120+
121+
private fun matchSetValue(value: UExpression, field: PsiField, declaration: PreviewStateDeclaration): PreviewClickAction? {
122+
val literal = value as? ULiteralExpression
123+
if (literal != null) {
124+
return when (declaration.kind) {
125+
PreviewStateKind.BOOLEAN -> (literal.value as? Boolean)?.let { PreviewClickAction.SetLiteral(declaration.id, it) }
126+
PreviewStateKind.INT -> (literal.value as? Int)?.let { PreviewClickAction.SetLiteral(declaration.id, it) }
127+
}
128+
}
129+
130+
if (value is UUnaryExpression && value.operator == UastPrefixOperator.LOGICAL_NOT) {
131+
if (declaration.kind != PreviewStateKind.BOOLEAN) return null
132+
return if (isGetCallOn(value.operand.skipParenthesizedExprDown(), field)) {
133+
PreviewClickAction.ToggleBoolean(declaration.id)
134+
} else {
135+
null
136+
}
137+
}
138+
139+
if (value is UBinaryExpression) {
140+
if (declaration.kind != PreviewStateKind.INT) return null
141+
val left = value.leftOperand.skipParenthesizedExprDown()
142+
val right = value.rightOperand.skipParenthesizedExprDown() as? ULiteralExpression
143+
val delta = right?.value as? Int ?: return null
144+
if (!isGetCallOn(left, field)) return null
145+
return when (value.operator) {
146+
UastBinaryOperator.PLUS -> PreviewClickAction.Delta(declaration.id, delta)
147+
UastBinaryOperator.MINUS -> PreviewClickAction.Delta(declaration.id, -delta)
148+
else -> null
149+
}
150+
}
151+
152+
return null
153+
}
154+
155+
private fun isGetCallOn(expr: UExpression, field: PsiField): Boolean {
156+
val call = asCallExpression(expr) ?: return false
157+
if (call.methodName != "get") return false
158+
val receiver = call.receiver?.skipParenthesizedExprDown() as? UReferenceExpression ?: return false
159+
return receiver.resolve() == field
160+
}
161+
}

intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewFileEditor.kt

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.intellij.openapi.actionSystem.AnAction
77
import com.intellij.openapi.actionSystem.AnActionEvent
88
import com.intellij.openapi.actionSystem.DefaultActionGroup
99
import com.intellij.openapi.actionSystem.ToggleAction
10+
import com.intellij.openapi.editor.Inlay
1011
import com.intellij.openapi.editor.ScrollType
1112
import com.intellij.openapi.editor.event.CaretEvent
1213
import com.intellij.openapi.editor.event.CaretListener
@@ -63,10 +64,13 @@ class InventoryPreviewFileEditor(
6364
private val propertyChangeSupport = PropertyChangeSupport(this)
6465
private val refreshAlarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this)
6566
private var currentModel: PreviewModel? = null
67+
private val interactionState = PreviewInteractionState()
68+
private var interactiveModeEnabled = false
69+
private val stateInlays = mutableListOf<Inlay<*>>()
6670
private val rootComponent: JComponent by lazy { buildComponent() }
6771

6872
init {
69-
panel.onSlotClicked = ::navigateToRange
73+
panel.onSlotClicked = ::onSlotClicked
7074
// The editor can be reconstructed (e.g. restoring last-open tabs on startup) while the
7175
// project is still indexing; retry once smart mode is reached instead of caching a
7276
// permanent extraction failure from that race.
@@ -99,8 +103,27 @@ class InventoryPreviewFileEditor(
99103
null
100104
}
101105
currentModel = model
102-
panel.setModel(model)
106+
interactionState.reset(model)
107+
panel.setModel(model?.let(interactionState::resolve))
103108
updateHighlightForCaret()
109+
refreshStateHints()
110+
}
111+
112+
// Shows the current simulated value next to each state field's declaration, e.g.
113+
// `mutableState(0); → 3`, while interactive mode is on. Rebuilt wholesale on every state
114+
// change rather than incrementally updated in place - cheap given how few state fields a
115+
// view typically has, and avoids tracking which inlay belongs to which declaration.
116+
private fun refreshStateHints() {
117+
stateInlays.forEach { it.dispose() }
118+
stateInlays.clear()
119+
if (!interactiveModeEnabled) return
120+
val model = currentModel ?: return
121+
val editor = textEditor.editor
122+
for (declaration in model.states) {
123+
val value = interactionState.currentValue(declaration.id) ?: continue
124+
val renderer = StateValueInlayRenderer("Current value: $value", declaration.declarationOffset)
125+
editor.inlayModel.addBlockElement(declaration.declarationOffset, false, true, 0, renderer)?.let { stateInlays += it }
126+
}
104127
}
105128

106129
private fun updateHighlightForCaret() {
@@ -120,6 +143,65 @@ class InventoryPreviewFileEditor(
120143
editor.contentComponent.requestFocusInWindow()
121144
}
122145

146+
private fun onSlotClicked(index: Int) {
147+
val model = currentModel ?: return
148+
if (interactiveModeEnabled) {
149+
simulateClick(model, index)
150+
} else {
151+
model.slots[index]?.sourceRange?.let(::navigateToRange)
152+
}
153+
}
154+
155+
private fun simulateClick(model: PreviewModel, index: Int) {
156+
when (val action = model.clickActions[index]) {
157+
null -> return
158+
PreviewClickAction.Unsupported -> showUnsupportedInteractionBalloon()
159+
else -> {
160+
interactionState.apply(action)
161+
panel.setModel(interactionState.resolve(model))
162+
refreshStateHints()
163+
showSimulatedActionBalloon(action)
164+
}
165+
}
166+
}
167+
168+
private fun showSimulatedActionBalloon(action: PreviewClickAction) {
169+
val (stateId, description) = when (action) {
170+
is PreviewClickAction.ToggleBoolean -> action.stateId to "toggled"
171+
is PreviewClickAction.Delta -> action.stateId to "changed by ${if (action.delta >= 0) "+" else ""}${action.delta}"
172+
is PreviewClickAction.SetLiteral -> action.stateId to "set to ${action.value}"
173+
PreviewClickAction.Unsupported -> return
174+
}
175+
val fieldName = stateId.substringAfterLast('#')
176+
JBPopupFactory.getInstance()
177+
.createHtmlTextBalloonBuilder("$fieldName $description", MessageType.INFO, null)
178+
.setFadeoutTime(COPY_FEEDBACK_FADEOUT_MILLIS.toLong())
179+
.createBalloon()
180+
.show(RelativePoint.getCenterOf(panel), Balloon.Position.above)
181+
}
182+
183+
private fun resetInteraction() {
184+
interactionState.reset(currentModel)
185+
currentModel?.let { panel.setModel(interactionState.resolve(it)) }
186+
refreshStateHints()
187+
}
188+
189+
private fun undoLastInteraction() {
190+
val model = currentModel ?: return
191+
if (interactionState.undo()) {
192+
panel.setModel(interactionState.resolve(model))
193+
refreshStateHints()
194+
}
195+
}
196+
197+
private fun showUnsupportedInteractionBalloon() {
198+
JBPopupFactory.getInstance()
199+
.createHtmlTextBalloonBuilder("This click handler can't be simulated in the fast preview", MessageType.WARNING, null)
200+
.setFadeoutTime(COPY_FEEDBACK_FADEOUT_MILLIS.toLong())
201+
.createBalloon()
202+
.show(RelativePoint.getCenterOf(panel), Balloon.Position.above)
203+
}
204+
123205
private fun buildComponent(): JComponent {
124206
val toolbar = ActionManager.getInstance().createActionToolbar(TOOLBAR_PLACE, createToolbarActions(), true)
125207
val wrapper = JPanel(BorderLayout())
@@ -172,6 +254,33 @@ class InventoryPreviewFileEditor(
172254
}
173255
override fun getActionUpdateThread() = ActionUpdateThread.EDT
174256
})
257+
group.addSeparator()
258+
group.add(
259+
object : ToggleAction(
260+
"Interactive Preview",
261+
"Click slots to simulate their click handlers instead of navigating to source",
262+
AllIcons.Actions.Execute,
263+
) {
264+
override fun isSelected(e: AnActionEvent) = interactiveModeEnabled
265+
override fun setSelected(e: AnActionEvent, state: Boolean) {
266+
interactiveModeEnabled = state
267+
resetInteraction()
268+
}
269+
override fun update(e: AnActionEvent) {
270+
super.update(e)
271+
e.presentation.icon = if (interactiveModeEnabled) AllIcons.Actions.Suspend else AllIcons.Actions.Execute
272+
e.presentation.text = if (interactiveModeEnabled) "Stop Interactive Preview" else "Start Interactive Preview"
273+
}
274+
override fun getActionUpdateThread() = ActionUpdateThread.EDT
275+
},
276+
)
277+
group.add(object : AnAction("Undo Last Interaction", "Revert the last simulated click", AllIcons.Actions.Undo) {
278+
override fun actionPerformed(e: AnActionEvent) = undoLastInteraction()
279+
override fun update(e: AnActionEvent) {
280+
e.presentation.isEnabled = interactiveModeEnabled && interactionState.canUndo()
281+
}
282+
override fun getActionUpdateThread() = ActionUpdateThread.EDT
283+
})
175284
return group
176285
}
177286

@@ -209,5 +318,8 @@ class InventoryPreviewFileEditor(
209318

210319
override fun getFile(): VirtualFile = file
211320

212-
override fun dispose() {}
321+
override fun dispose() {
322+
stateInlays.forEach { it.dispose() }
323+
stateInlays.clear()
324+
}
213325
}

intellij-plugin/src/main/kotlin/me/devnatan/inventoryframework/intellij/InventoryPreviewPanel.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package me.devnatan.inventoryframework.intellij
22

3-
import com.intellij.openapi.util.TextRange
43
import com.intellij.ui.JBColor
54
import java.awt.Color
65
import java.awt.Dimension
@@ -75,7 +74,7 @@ class InventoryPreviewPanel : JPanel() {
7574
private var zoomIndex = DEFAULT_ZOOM_INDEX
7675
private val zoom: Double get() = ZOOM_LEVELS[zoomIndex]
7776

78-
var onSlotClicked: ((TextRange) -> Unit)? = null
77+
var onSlotClicked: ((Int) -> Unit)? = null
7978

8079
var showSlotNumbers: Boolean = false
8180
set(value) {
@@ -97,8 +96,7 @@ class InventoryPreviewPanel : JPanel() {
9796
val currentModel = model ?: return
9897
val logicalPoint = Point((e.x / zoom).toInt(), (e.y / zoom).toInt())
9998
val index = slotIndexAt(currentModel, logicalPoint) ?: return
100-
val range = currentModel.slots[index]?.sourceRange ?: return
101-
onSlotClicked?.invoke(range)
99+
onSlotClicked?.invoke(index)
102100
}
103101
})
104102
}

0 commit comments

Comments
 (0)