diff --git a/api/Elementa.api b/api/Elementa.api index e77bd466..82c967cf 100644 --- a/api/Elementa.api +++ b/api/Elementa.api @@ -1158,6 +1158,8 @@ public final class gg/essential/elementa/components/inspector/Inspector : gg/ess } public final class gg/essential/elementa/components/inspector/Inspector$Companion { + public final fun registerComponentFactory (Ljava/lang/Class;Ljava/lang/String;)V + public static synthetic fun registerComponentFactory$default (Lgg/essential/elementa/components/inspector/Inspector$Companion;Ljava/lang/Class;Ljava/lang/String;ILjava/lang/Object;)V } public final class gg/essential/elementa/components/inspector/InspectorNode : gg/essential/elementa/components/TreeNode { diff --git a/src/main/kotlin/gg/essential/elementa/UIComponent.kt b/src/main/kotlin/gg/essential/elementa/UIComponent.kt index 292a6d57..c5212a2f 100644 --- a/src/main/kotlin/gg/essential/elementa/UIComponent.kt +++ b/src/main/kotlin/gg/essential/elementa/UIComponent.kt @@ -6,6 +6,7 @@ import gg.essential.elementa.components.UIBlock import gg.essential.elementa.components.UIContainer import gg.essential.elementa.components.UpdateFunc import gg.essential.elementa.components.Window +import gg.essential.elementa.components.inspector.Inspector import gg.essential.elementa.constraints.* import gg.essential.elementa.constraints.animation.* import gg.essential.elementa.dsl.animate @@ -1310,6 +1311,25 @@ abstract class UIComponent : Observable(), ReferenceHolder { } //endregion + //region Source code location + internal val source: Array? = if (elementaDev) Throwable().stackTrace else null + + internal val filteredSource: List? + get() = source?.filterNot { it.lineNumber == 1 } + + internal val primarySource: StackTraceElement? + get() { + val className = javaClass.name + return (source ?: return null) + .asSequence() + .dropWhile { it.methodName == "" && it.className != className } // super class constructors + .dropWhile { it.methodName == "" && it.className == className } // constructors + .filterNot { it.lineNumber == 1 } // ignore synthetic methods + .dropWhile { frame -> Inspector.factoryMethods.any { (c, m) -> c == frame.className && (m == null || m == frame.methodName) } } + .firstOrNull() + } + //endregion + /** * Field animation API */ diff --git a/src/main/kotlin/gg/essential/elementa/components/UIImage.kt b/src/main/kotlin/gg/essential/elementa/components/UIImage.kt index cfd67450..500cf055 100644 --- a/src/main/kotlin/gg/essential/elementa/components/UIImage.kt +++ b/src/main/kotlin/gg/essential/elementa/components/UIImage.kt @@ -2,6 +2,7 @@ package gg.essential.elementa.components import gg.essential.elementa.UIComponent import gg.essential.elementa.components.image.* +import gg.essential.elementa.components.inspector.Inspector import gg.essential.elementa.utils.ResourceCache import gg.essential.elementa.utils.drawTexture import gg.essential.universal.UGraphics @@ -127,6 +128,10 @@ open class UIImage @JvmOverloads constructor( val defaultResourceCache = ResourceCache(50) + init { + Inspector.registerComponentFactory(Companion::class.java) + } + @JvmStatic fun ofFile(file: File): UIImage { return UIImage(CompletableFuture.supplyAsync { ImageIO.read(file) }) diff --git a/src/main/kotlin/gg/essential/elementa/components/inspector/Inspector.kt b/src/main/kotlin/gg/essential/elementa/components/inspector/Inspector.kt index 803e6625..f0cea0ca 100644 --- a/src/main/kotlin/gg/essential/elementa/components/inspector/Inspector.kt +++ b/src/main/kotlin/gg/essential/elementa/components/inspector/Inspector.kt @@ -10,11 +10,15 @@ import gg.essential.elementa.effects.ScissorEffect import gg.essential.elementa.utils.ObservableAddEvent import gg.essential.elementa.utils.ObservableClearEvent import gg.essential.elementa.utils.ObservableRemoveEvent +import gg.essential.elementa.utils.devPropSet import gg.essential.elementa.utils.elementaDebug import gg.essential.universal.UGraphics import gg.essential.universal.UMatrixStack import org.lwjgl.opengl.GL11 import java.awt.Color +import java.io.FileNotFoundException +import java.net.ConnectException +import java.net.URL import java.text.NumberFormat class Inspector @JvmOverloads constructor( @@ -201,6 +205,59 @@ class Inspector @JvmOverloads constructor( infoBlockScroller childOf container } selectedNode = node + if (node != null) { + val source = node.targetComponent.primarySource + if (source != null) { + node.selectedSourceIndex = node.targetComponent.filteredSource!!.indexOf(source) + openSourceFile(source) + } + } + } + + init { + // Workaround for ScrollComponent.actualContent breaking scroll event propagation + var recursive = false + onMouseScroll { event -> + if (recursive) return@onMouseScroll + recursive = true + treeBlock.mouseScroll(event.delta) + recursive = false + } + } + + internal fun scrollSource(node: InspectorNode, up: Boolean) { + val elements = node.targetComponent.filteredSource ?: return + var index = node.selectedSourceIndex + index = (index + if (up) 1 else -1).coerceIn(elements.indices) + node.selectedSourceIndex = index + openSourceFile(elements[index]) + } + + private fun openSourceFile(frame: StackTraceElement) { + val folder = frame.className.substringBeforeLast(".").replace(".", "/") + val file = folder + "/" + frame.fileName + val line = frame.lineNumber + try { + // For docs see https://www.develar.org/idea-rest-api/#api-Platform-file + // For impl see https://github.com/JetBrains/intellij-community/blob/d9b508478de6d3b5d2e765738d561ead77c97824/plugins/remote-control/src/org/jetbrains/ide/OpenFileHttpService.kt + val url = URL("$INTELLIJ_REST_API/api/file?file=$file&line=$line&focused=false") + val conn = url.openConnection() + // IntelliJ uses Origin/Referrer to determine whether to trust a given request (a JavaScript snippet in your + // browser could try to request this page too after all!), so we use localhost which is trusted by default. + // See https://github.com/JetBrains/intellij-community/blob/d9b508478de6d3b5d2e765738d561ead77c97824/platform/built-in-server/src/org/jetbrains/ide/RestService.kt#L272 + // We can't use Origin because Java considers that a restricted header and will just ignore it. + conn.setRequestProperty("Referer", "http://localhost") + conn.connect() + conn.inputStream.skip(Long.MAX_VALUE) + } catch (ignored: ConnectException) { + } catch (e: FileNotFoundException) { // HTTP 404 + if (!hintedIntelliJSupport) { + hintedIntelliJSupport = true + println("IntelliJ detected! Install JetBrain's `IDE Remote Control` plugin to automatically jump to the source of the selected component.") + } + } catch (e: Exception) { + e.printStackTrace() + } } private fun getClickSelectTarget(mouseX: Float, mouseY: Float): UIComponent? { @@ -283,5 +340,22 @@ class Inspector @JvmOverloads constructor( companion object { internal val percentFormat: NumberFormat = NumberFormat.getPercentInstance() + + private val INTELLIJ_REST_API = System.getProperty("elementa.intellij_rest_api", "http://localhost:63342") + private var hintedIntelliJSupport = false + + internal val factoryMethods: MutableList> = mutableListOf() + + fun registerComponentFactory(cls: Class<*>?, method: String? = null) { + if (!devPropSet) return + factoryMethods.add(Pair(cls?.name ?: callerClassName(), method)) + } + + private fun callerClassName(): String = + Throwable().stackTrace.asSequence() + .filterNot { it.methodName.endsWith("\$default") } // synthetic Kotlin defaults method + .drop(2) // this method + caller of this method + .first() + .className } } diff --git a/src/main/kotlin/gg/essential/elementa/components/inspector/InspectorNode.kt b/src/main/kotlin/gg/essential/elementa/components/inspector/InspectorNode.kt index 070c7a22..71876b8e 100644 --- a/src/main/kotlin/gg/essential/elementa/components/inspector/InspectorNode.kt +++ b/src/main/kotlin/gg/essential/elementa/components/inspector/InspectorNode.kt @@ -11,31 +11,35 @@ import gg.essential.elementa.dsl.toConstraint import gg.essential.elementa.dsl.childOf import gg.essential.elementa.dsl.constrain import gg.essential.elementa.dsl.pixels +import gg.essential.universal.UKeyboard +import gg.essential.universal.UMatrixStack import java.awt.Color class InspectorNode(private val inspector: Inspector, val targetComponent: UIComponent) : TreeNode() { private val componentClassName = targetComponent.javaClass.simpleName.ifEmpty { "UnknownType" } private val componentDisplayName = targetComponent.componentName.let { if (it == componentClassName) it else "$componentClassName: $it" } - private var wasHidden = false + + private var selectedSource = targetComponent.primarySource + internal var selectedSourceIndex: Int = targetComponent.filteredSource?.indexOf(selectedSource) ?: 0 + set(value) { + field = value + selectedSource = targetComponent.filteredSource?.get(value) + } private val component: UIComponent = object : UIBlock(Color(0, 0, 0, 0)) { private val text = UIText(componentDisplayName).constrain { width = TextAspectConstraint() } childOf this - override fun animationFrame() { - super.animationFrame() + override fun draw(matrixStack: UMatrixStack) { + super.draw(matrixStack) val isCurrentlyHidden = targetComponent.parent != targetComponent && !targetComponent.parent.children.contains( targetComponent ) - if (isCurrentlyHidden && !wasHidden) { - wasHidden = true - text.setText("§r$componentDisplayName §7§o(Hidden)") - } else if (!isCurrentlyHidden && wasHidden) { - wasHidden = false - text.setText(componentDisplayName) - } + val file = selectedSource?.let { " §7${it.fileName ?: it.className.substringAfterLast(".")}:${it.lineNumber}" } ?: "" + val hidden = if (isCurrentlyHidden) " §7§o(Hidden)" else "" + text.setText("§r$componentDisplayName$file$hidden") } }.constrain { x = SiblingConstraint() @@ -45,6 +49,10 @@ class InspectorNode(private val inspector: Inspector, val targetComponent: UICom }.onMouseClick { event -> event.stopImmediatePropagation() toggleSelection() + }.onMouseScroll { event -> + if (!UKeyboard.isShiftKeyDown()) return@onMouseScroll + event.stopImmediatePropagation() + inspector.scrollSource(this@InspectorNode, event.delta > 0) } internal fun toggleSelection() { diff --git a/src/main/kotlin/gg/essential/elementa/utils/ResourceCache.kt b/src/main/kotlin/gg/essential/elementa/utils/ResourceCache.kt index f08b9413..734d86dc 100644 --- a/src/main/kotlin/gg/essential/elementa/utils/ResourceCache.kt +++ b/src/main/kotlin/gg/essential/elementa/utils/ResourceCache.kt @@ -3,6 +3,7 @@ package gg.essential.elementa.utils import gg.essential.elementa.components.UIImage import gg.essential.elementa.components.image.CacheableImage import gg.essential.elementa.components.image.MSDFComponent +import gg.essential.elementa.components.inspector.Inspector import java.awt.image.BufferedImage import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap @@ -44,4 +45,10 @@ class ResourceCache(val size: Int = 50) { cachedImage.supply(it) } } + + private companion object { + init { + Inspector.registerComponentFactory(ResourceCache::class.java) + } + } } \ No newline at end of file