diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt index ba6cdabcb9ff..68cd7d188574 100644 --- a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/JewelComposePanelWrapper.kt @@ -21,6 +21,7 @@ import org.jetbrains.jewel.bridge.actionSystem.ComponentDataProviderBridge import org.jetbrains.jewel.bridge.component.JBPopupRenderer import org.jetbrains.jewel.bridge.theme.SwingBridgeTheme import org.jetbrains.jewel.foundation.ExperimentalJewelApi +import org.jetbrains.jewel.foundation.InternalJewelApi import org.jetbrains.jewel.foundation.LocalComponent as LocalComponentFoundation import org.jetbrains.jewel.foundation.util.JewelLogger import org.jetbrains.jewel.ui.component.LocalPopupRenderer @@ -210,7 +211,9 @@ private fun createJewelComposePanel( return jewelPanel } -internal class JewelComposePanelWrapper(private val focusOnClickInside: Boolean) : BorderLayoutPanel(), UiDataProvider { +@ApiStatus.Internal +@InternalJewelApi +public class JewelComposePanelWrapper(private val focusOnClickInside: Boolean) : BorderLayoutPanel(), UiDataProvider { internal var targetProvider: UiDataProvider? = null private val listener = AWTEventListener { event -> if (event !is MouseEvent || event.button == MouseEvent.NOBUTTON) return@AWTEventListener @@ -219,7 +222,7 @@ internal class JewelComposePanelWrapper(private val focusOnClickInside: Boolean) } } - val composePanel: ComposePanel = ComposePanel() + public val composePanel: ComposePanel = ComposePanel() init { super.addToCenter(composePanel) diff --git a/plugins/performanceTesting/remote-driver/BUILD.bazel b/plugins/performanceTesting/remote-driver/BUILD.bazel index 3a63df2529f5..1fc60b3f689e 100644 --- a/plugins/performanceTesting/remote-driver/BUILD.bazel +++ b/plugins/performanceTesting/remote-driver/BUILD.bazel @@ -33,6 +33,8 @@ jvm_library( "//libraries/jcef", "//platform/platform-impl:ide-impl", "//platform/ui.jcef", + "//platform/compose", + "//platform/core-ui", ], exports = [ "//platform/remote-driver/core:driver-impl", diff --git a/plugins/performanceTesting/remote-driver/intellij.performanceTesting.remoteDriver.iml b/plugins/performanceTesting/remote-driver/intellij.performanceTesting.remoteDriver.iml index eef02af69efb..5b41e8772af8 100644 --- a/plugins/performanceTesting/remote-driver/intellij.performanceTesting.remoteDriver.iml +++ b/plugins/performanceTesting/remote-driver/intellij.performanceTesting.remoteDriver.iml @@ -27,5 +27,7 @@ + + \ No newline at end of file diff --git a/plugins/performanceTesting/remote-driver/resources/intellij.performanceTesting.remoteDriver.xml b/plugins/performanceTesting/remote-driver/resources/intellij.performanceTesting.remoteDriver.xml index 035a6e7750c4..e9c6b06bcdd8 100644 --- a/plugins/performanceTesting/remote-driver/resources/intellij.performanceTesting.remoteDriver.xml +++ b/plugins/performanceTesting/remote-driver/resources/intellij.performanceTesting.remoteDriver.xml @@ -1,4 +1,8 @@ + + + + @@ -18,5 +22,6 @@ + \ No newline at end of file diff --git a/plugins/performanceTesting/remote-driver/src/com/jetbrains/performancePlugin/remotedriver/xpath/ComposeXpathDataModelExtension.kt b/plugins/performanceTesting/remote-driver/src/com/jetbrains/performancePlugin/remotedriver/xpath/ComposeXpathDataModelExtension.kt new file mode 100644 index 000000000000..1ad0f9831622 --- /dev/null +++ b/plugins/performanceTesting/remote-driver/src/com/jetbrains/performancePlugin/remotedriver/xpath/ComposeXpathDataModelExtension.kt @@ -0,0 +1,192 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.performancePlugin.remotedriver.xpath + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import com.intellij.ui.scale.JBUIScale +import com.jetbrains.performancePlugin.jmxDriver.InvokerService +import org.jetbrains.jewel.bridge.JewelComposePanelWrapper +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.awt.Component +import java.awt.Dimension +import java.awt.Point +import java.util.Collections + +/** + * A lightweight wrapper component that represents a Compose SemanticsNode. + * This allows the remote driver to treat each Compose UI element as a distinct component + * with its own bounds and location, even though they all belong to the same ComposePanel. + * + * Also we need to take into account scaling since compose returns physical pixels and AWT scaled. + */ +private class ComposeSemanticsNodeWrapper( + private val node: SemanticsNode, + private val panel: ComposePanel, +) : Component() { + private val scale = JBUIScale.sysScale(panel) + + override fun getLocationOnScreen(): Point { + val bounds = node.boundsInRoot + return Point( + (bounds.left / scale).toInt() + panel.locationOnScreen.x, + (bounds.top / scale).toInt() + panel.locationOnScreen.y + ) + } + + override fun getSize(): Dimension { + return node.boundsInWindow.size.let { Dimension((it.width / scale).toInt(), (it.height / scale).toInt()) } + } + + override fun getX(): Int { + return (node.boundsInWindow.left / scale).toInt() + } + + override fun getY(): Int { + return (node.boundsInWindow.top / scale).toInt() + } + + override fun getWidth(): Int { + return (node.boundsInWindow.width / scale).toInt() + } + + override fun getHeight(): Int { + return (node.boundsInWindow.height / scale).toInt() + } + + override fun isShowing(): Boolean { + return panel.isShowing && (node.config.getOrNull(SemanticsProperties.Disabled) == null) + } + + override fun isVisible(): Boolean { + return panel.isVisible && (node.config.getOrNull(SemanticsProperties.Disabled) == null) + } + + override fun toString(): String { + val text = node.config.getOrNull(SemanticsProperties.Text)?.joinToString { it.text } + val role = node.config.getOrNull(SemanticsProperties.Role) + return "ComposeSemanticsNode(role=$role, text=$text)" + } +} + +class ComposeXpathDataModelExtension : XpathDataModelExtension { + override val isRemDevExtension: Boolean = false + + @OptIn(ExperimentalComposeUiApi::class) + override fun postProcessElement(doc: Document, component: Component, element: Element, parentElement: Element) { + val composePanel = when (component) { + is JewelComposePanelWrapper -> component.composePanel + else -> return + } + + // Clear the cache before processing to remove stale references from previous UI snapshots + // The UI hierarchy is rebuilt on each request, so old wrappers are no longer needed + wrapperCache.clear() + + try { + val semanticsOwners = composePanel.semanticsOwners + semanticsOwners.forEach { owner -> + traverseComposeTree(doc, element, owner.rootSemanticsNode, composePanel) + } + } + catch (_: Exception) { + return + } + } + + private fun traverseComposeTree(doc: Document, parentElement: Element, node: SemanticsNode, composePanel: ComposePanel) { + val composeElement = doc.createElement("div") + composeElement.setAttribute("class", "ComposeNode") + composeElement.setAttribute("javaclass", "androidx.compose.ui.semantics.SemanticsNode") + + val wrapperComponent = ComposeSemanticsNodeWrapper(node, composePanel) + composeElement.setUserData("component", wrapperComponent, null) + + val ref = InvokerService.getInstance().putReference(wrapperComponent) + wrapperCache.add(wrapperComponent) + + composeElement.setAttribute("refId", ref.id) + composeElement.setAttribute("hashCode", ref.identityHashCode.toString()) + composeElement.setAttribute("asString", ref.asString()) + + val rdTarget = InvokerService.getInstance().rdTarget + composeElement.setAttribute("rdTarget", rdTarget.toString()) + + val role = node.config.getOrNull(SemanticsProperties.Role)?.toString() ?: "ComposeNode" + + val textContent = buildString { + node.config.getOrNull(SemanticsProperties.Text)?.forEach { text -> + if (isNotEmpty()) append(" || ") + append(text.text) + } + } + + val textNodeContent = StringBuilder("$role. ") + + if (textContent.isNotEmpty()) { + composeElement.setAttribute("text", textContent) + composeElement.setAttribute("visible_text", textContent) + textNodeContent.append("text: '$textContent'. ") + } + + node.config.getOrNull(SemanticsProperties.EditableText)?.let { editableText -> + composeElement.setAttribute("editabletext", editableText.text) + if (textContent.isEmpty()) { + textNodeContent.append("editabletext: '${editableText.text}'. ") + } + } + + composeElement.appendChild(doc.createTextNode(textNodeContent.toString())) + + node.config.getOrNull(SemanticsProperties.ContentDescription)?.let { descriptions -> + val contentDesc = descriptions.joinToString(" ") + composeElement.setAttribute("contentdescription", contentDesc) + } + + node.config.getOrNull(SemanticsProperties.Role)?.let { role -> + composeElement.setAttribute("role", role.toString()) + } + + node.config.getOrNull(SemanticsProperties.TestTag)?.let { testTag -> + composeElement.setAttribute("testtag", testTag) + } + + node.config.getOrNull(SemanticsProperties.ToggleableState)?.let { state -> + composeElement.setAttribute("toggleablestate", state.toString()) + } + + node.config.getOrNull(SemanticsProperties.Selected)?.let { selected -> + composeElement.setAttribute("selected", selected.toString()) + } + + node.config.getOrNull(SemanticsProperties.Focused)?.let { focused -> + composeElement.setAttribute("focused", focused.toString()) + } + + node.config.getOrNull(SemanticsProperties.Disabled)?.let { + composeElement.setAttribute("disabled", "true") + } + + node.config.getOrNull(SemanticsProperties.Heading)?.let { + composeElement.setAttribute("heading", "true") + } + + node.config.getOrNull(SemanticsProperties.ProgressBarRangeInfo)?.let { progressInfo -> + composeElement.setAttribute("progresscurrent", progressInfo.current.toString()) + composeElement.setAttribute("progressrange", "${progressInfo.range.start}-${progressInfo.range.endInclusive}") + } + + parentElement.appendChild(composeElement) + + node.children.forEach { child -> + traverseComposeTree(doc, composeElement, child, composePanel) + } + } +} + +// Cache to maintain strong references to wrapper components +// This prevents garbage collection of wrappers that are registered with InvokerService +private val wrapperCache = Collections.synchronizedList(mutableListOf()) \ No newline at end of file