Skip to content

Commit 2d61e23

Browse files
authored
Inspector: Show source file of components and jump there in IntelliJ
This commit tries to track the source code location of components (not where its class is defined but where that particular instance was created) and shows that info in the inspector. Should the automatically determined location be unhelpful (e.g. because it's in a factory function rather than where the factory function is called), then it also allows scrolling while holding the Shift key to go up and down the call stack. If IntelliJ is running, it will also automatically jump to that source location every time a component is selected in the inspector. GitHub: #158
1 parent e269865 commit 2d61e23

File tree

6 files changed

+126
-10
lines changed

6 files changed

+126
-10
lines changed

api/Elementa.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,6 +1158,8 @@ public final class gg/essential/elementa/components/inspector/Inspector : gg/ess
11581158
}
11591159

11601160
public final class gg/essential/elementa/components/inspector/Inspector$Companion {
1161+
public final fun registerComponentFactory (Ljava/lang/Class;Ljava/lang/String;)V
1162+
public static synthetic fun registerComponentFactory$default (Lgg/essential/elementa/components/inspector/Inspector$Companion;Ljava/lang/Class;Ljava/lang/String;ILjava/lang/Object;)V
11611163
}
11621164

11631165
public final class gg/essential/elementa/components/inspector/InspectorNode : gg/essential/elementa/components/TreeNode {

src/main/kotlin/gg/essential/elementa/UIComponent.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import gg.essential.elementa.components.UIBlock
66
import gg.essential.elementa.components.UIContainer
77
import gg.essential.elementa.components.UpdateFunc
88
import gg.essential.elementa.components.Window
9+
import gg.essential.elementa.components.inspector.Inspector
910
import gg.essential.elementa.constraints.*
1011
import gg.essential.elementa.constraints.animation.*
1112
import gg.essential.elementa.dsl.animate
@@ -1310,6 +1311,25 @@ abstract class UIComponent : Observable(), ReferenceHolder {
13101311
}
13111312
//endregion
13121313

1314+
//region Source code location
1315+
internal val source: Array<StackTraceElement>? = if (elementaDev) Throwable().stackTrace else null
1316+
1317+
internal val filteredSource: List<StackTraceElement>?
1318+
get() = source?.filterNot { it.lineNumber == 1 }
1319+
1320+
internal val primarySource: StackTraceElement?
1321+
get() {
1322+
val className = javaClass.name
1323+
return (source ?: return null)
1324+
.asSequence()
1325+
.dropWhile { it.methodName == "<init>" && it.className != className } // super class constructors
1326+
.dropWhile { it.methodName == "<init>" && it.className == className } // constructors
1327+
.filterNot { it.lineNumber == 1 } // ignore synthetic methods
1328+
.dropWhile { frame -> Inspector.factoryMethods.any { (c, m) -> c == frame.className && (m == null || m == frame.methodName) } }
1329+
.firstOrNull()
1330+
}
1331+
//endregion
1332+
13131333
/**
13141334
* Field animation API
13151335
*/

src/main/kotlin/gg/essential/elementa/components/UIImage.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package gg.essential.elementa.components
22

33
import gg.essential.elementa.UIComponent
44
import gg.essential.elementa.components.image.*
5+
import gg.essential.elementa.components.inspector.Inspector
56
import gg.essential.elementa.utils.ResourceCache
67
import gg.essential.elementa.utils.drawTexture
78
import gg.essential.universal.UGraphics
@@ -127,6 +128,10 @@ open class UIImage @JvmOverloads constructor(
127128

128129
val defaultResourceCache = ResourceCache(50)
129130

131+
init {
132+
Inspector.registerComponentFactory(Companion::class.java)
133+
}
134+
130135
@JvmStatic
131136
fun ofFile(file: File): UIImage {
132137
return UIImage(CompletableFuture.supplyAsync { ImageIO.read(file) })

src/main/kotlin/gg/essential/elementa/components/inspector/Inspector.kt

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,15 @@ import gg.essential.elementa.effects.ScissorEffect
1010
import gg.essential.elementa.utils.ObservableAddEvent
1111
import gg.essential.elementa.utils.ObservableClearEvent
1212
import gg.essential.elementa.utils.ObservableRemoveEvent
13+
import gg.essential.elementa.utils.devPropSet
1314
import gg.essential.elementa.utils.elementaDebug
1415
import gg.essential.universal.UGraphics
1516
import gg.essential.universal.UMatrixStack
1617
import org.lwjgl.opengl.GL11
1718
import java.awt.Color
19+
import java.io.FileNotFoundException
20+
import java.net.ConnectException
21+
import java.net.URL
1822
import java.text.NumberFormat
1923

2024
class Inspector @JvmOverloads constructor(
@@ -201,6 +205,59 @@ class Inspector @JvmOverloads constructor(
201205
infoBlockScroller childOf container
202206
}
203207
selectedNode = node
208+
if (node != null) {
209+
val source = node.targetComponent.primarySource
210+
if (source != null) {
211+
node.selectedSourceIndex = node.targetComponent.filteredSource!!.indexOf(source)
212+
openSourceFile(source)
213+
}
214+
}
215+
}
216+
217+
init {
218+
// Workaround for ScrollComponent.actualContent breaking scroll event propagation
219+
var recursive = false
220+
onMouseScroll { event ->
221+
if (recursive) return@onMouseScroll
222+
recursive = true
223+
treeBlock.mouseScroll(event.delta)
224+
recursive = false
225+
}
226+
}
227+
228+
internal fun scrollSource(node: InspectorNode, up: Boolean) {
229+
val elements = node.targetComponent.filteredSource ?: return
230+
var index = node.selectedSourceIndex
231+
index = (index + if (up) 1 else -1).coerceIn(elements.indices)
232+
node.selectedSourceIndex = index
233+
openSourceFile(elements[index])
234+
}
235+
236+
private fun openSourceFile(frame: StackTraceElement) {
237+
val folder = frame.className.substringBeforeLast(".").replace(".", "/")
238+
val file = folder + "/" + frame.fileName
239+
val line = frame.lineNumber
240+
try {
241+
// For docs see https://www.develar.org/idea-rest-api/#api-Platform-file
242+
// For impl see https://github.com/JetBrains/intellij-community/blob/d9b508478de6d3b5d2e765738d561ead77c97824/plugins/remote-control/src/org/jetbrains/ide/OpenFileHttpService.kt
243+
val url = URL("$INTELLIJ_REST_API/api/file?file=$file&line=$line&focused=false")
244+
val conn = url.openConnection()
245+
// IntelliJ uses Origin/Referrer to determine whether to trust a given request (a JavaScript snippet in your
246+
// browser could try to request this page too after all!), so we use localhost which is trusted by default.
247+
// See https://github.com/JetBrains/intellij-community/blob/d9b508478de6d3b5d2e765738d561ead77c97824/platform/built-in-server/src/org/jetbrains/ide/RestService.kt#L272
248+
// We can't use Origin because Java considers that a restricted header and will just ignore it.
249+
conn.setRequestProperty("Referer", "http://localhost")
250+
conn.connect()
251+
conn.inputStream.skip(Long.MAX_VALUE)
252+
} catch (ignored: ConnectException) {
253+
} catch (e: FileNotFoundException) { // HTTP 404
254+
if (!hintedIntelliJSupport) {
255+
hintedIntelliJSupport = true
256+
println("IntelliJ detected! Install JetBrain's `IDE Remote Control` plugin to automatically jump to the source of the selected component.")
257+
}
258+
} catch (e: Exception) {
259+
e.printStackTrace()
260+
}
204261
}
205262

206263
private fun getClickSelectTarget(mouseX: Float, mouseY: Float): UIComponent? {
@@ -283,5 +340,22 @@ class Inspector @JvmOverloads constructor(
283340

284341
companion object {
285342
internal val percentFormat: NumberFormat = NumberFormat.getPercentInstance()
343+
344+
private val INTELLIJ_REST_API = System.getProperty("elementa.intellij_rest_api", "http://localhost:63342")
345+
private var hintedIntelliJSupport = false
346+
347+
internal val factoryMethods: MutableList<Pair<String, String?>> = mutableListOf()
348+
349+
fun registerComponentFactory(cls: Class<*>?, method: String? = null) {
350+
if (!devPropSet) return
351+
factoryMethods.add(Pair(cls?.name ?: callerClassName(), method))
352+
}
353+
354+
private fun callerClassName(): String =
355+
Throwable().stackTrace.asSequence()
356+
.filterNot { it.methodName.endsWith("\$default") } // synthetic Kotlin defaults method
357+
.drop(2) // this method + caller of this method
358+
.first()
359+
.className
286360
}
287361
}

src/main/kotlin/gg/essential/elementa/components/inspector/InspectorNode.kt

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,31 +11,35 @@ import gg.essential.elementa.dsl.toConstraint
1111
import gg.essential.elementa.dsl.childOf
1212
import gg.essential.elementa.dsl.constrain
1313
import gg.essential.elementa.dsl.pixels
14+
import gg.essential.universal.UKeyboard
15+
import gg.essential.universal.UMatrixStack
1416
import java.awt.Color
1517

1618
class InspectorNode(private val inspector: Inspector, val targetComponent: UIComponent) : TreeNode() {
1719
private val componentClassName = targetComponent.javaClass.simpleName.ifEmpty { "UnknownType" }
1820
private val componentDisplayName = targetComponent.componentName.let { if (it == componentClassName) it else "$componentClassName: $it" }
19-
private var wasHidden = false
21+
22+
private var selectedSource = targetComponent.primarySource
23+
internal var selectedSourceIndex: Int = targetComponent.filteredSource?.indexOf(selectedSource) ?: 0
24+
set(value) {
25+
field = value
26+
selectedSource = targetComponent.filteredSource?.get(value)
27+
}
2028

2129
private val component: UIComponent = object : UIBlock(Color(0, 0, 0, 0)) {
2230
private val text = UIText(componentDisplayName).constrain {
2331
width = TextAspectConstraint()
2432
} childOf this
2533

26-
override fun animationFrame() {
27-
super.animationFrame()
34+
override fun draw(matrixStack: UMatrixStack) {
35+
super.draw(matrixStack)
2836

2937
val isCurrentlyHidden = targetComponent.parent != targetComponent && !targetComponent.parent.children.contains(
3038
targetComponent
3139
)
32-
if (isCurrentlyHidden && !wasHidden) {
33-
wasHidden = true
34-
text.setText("§r$componentDisplayName §7§o(Hidden)")
35-
} else if (!isCurrentlyHidden && wasHidden) {
36-
wasHidden = false
37-
text.setText(componentDisplayName)
38-
}
40+
val file = selectedSource?.let { " §7${it.fileName ?: it.className.substringAfterLast(".")}:${it.lineNumber}" } ?: ""
41+
val hidden = if (isCurrentlyHidden) " §7§o(Hidden)" else ""
42+
text.setText("§r$componentDisplayName$file$hidden")
3943
}
4044
}.constrain {
4145
x = SiblingConstraint()
@@ -45,6 +49,10 @@ class InspectorNode(private val inspector: Inspector, val targetComponent: UICom
4549
}.onMouseClick { event ->
4650
event.stopImmediatePropagation()
4751
toggleSelection()
52+
}.onMouseScroll { event ->
53+
if (!UKeyboard.isShiftKeyDown()) return@onMouseScroll
54+
event.stopImmediatePropagation()
55+
inspector.scrollSource(this@InspectorNode, event.delta > 0)
4856
}
4957

5058
internal fun toggleSelection() {

src/main/kotlin/gg/essential/elementa/utils/ResourceCache.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package gg.essential.elementa.utils
33
import gg.essential.elementa.components.UIImage
44
import gg.essential.elementa.components.image.CacheableImage
55
import gg.essential.elementa.components.image.MSDFComponent
6+
import gg.essential.elementa.components.inspector.Inspector
67
import java.awt.image.BufferedImage
78
import java.util.concurrent.CompletableFuture
89
import java.util.concurrent.ConcurrentHashMap
@@ -44,4 +45,10 @@ class ResourceCache(val size: Int = 50) {
4445
cachedImage.supply(it)
4546
}
4647
}
48+
49+
private companion object {
50+
init {
51+
Inspector.registerComponentFactory(ResourceCache::class.java)
52+
}
53+
}
4754
}

0 commit comments

Comments
 (0)