From 096b8fc88936d1145ca5a5951c79e174e133db64 Mon Sep 17 00:00:00 2001 From: Nikolai Rykunov Date: Mon, 4 Sep 2023 21:13:53 +0200 Subject: [PATCH] Introduce DataProvider support (#110) * Introduce DataProvider support * Code style * Code style * Fix detektTest issue with Unit type (cherry picked from commit 8c9f591780847c640d1feda5430a40fdcbc734dd) --- .../actionSystem/DataProviderElement.kt | 24 ++++ .../bridge/actionSystem/DataProviderNode.kt | 58 +++++++++ .../jewel/bridge/actionSystem/ProvideData.kt | 111 +++++++++++++++++ .../bridge/actionSystem/ProvideDataTest.kt | 115 ++++++++++++++++++ 4 files changed, 308 insertions(+) create mode 100644 ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/DataProviderElement.kt create mode 100644 ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/DataProviderNode.kt create mode 100644 ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ProvideData.kt create mode 100644 ide-laf-bridge/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ProvideDataTest.kt diff --git a/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/DataProviderElement.kt b/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/DataProviderElement.kt new file mode 100644 index 000000000..3ecffabda --- /dev/null +++ b/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/DataProviderElement.kt @@ -0,0 +1,24 @@ +package org.jetbrains.jewel.bridge.actionSystem + +import androidx.compose.ui.node.ModifierNodeElement + +internal class DataProviderElement(val dataProvider: (dataId: String) -> Any?) : ModifierNodeElement() { + + override fun create(): DataProviderNode = DataProviderNode(dataProvider) + + override fun update(node: DataProviderNode) { + node.dataProvider = dataProvider + node.updateParent() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DataProviderElement + + return dataProvider == other.dataProvider + } + + override fun hashCode(): Int = dataProvider.hashCode() +} diff --git a/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/DataProviderNode.kt b/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/DataProviderNode.kt new file mode 100644 index 000000000..4101b8f8f --- /dev/null +++ b/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/DataProviderNode.kt @@ -0,0 +1,58 @@ +package org.jetbrains.jewel.bridge.actionSystem + +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusEventModifierNode +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.modifier.ModifierLocalMap +import androidx.compose.ui.modifier.ModifierLocalModifierNode +import androidx.compose.ui.modifier.modifierLocalMapOf +import androidx.compose.ui.modifier.modifierLocalOf + +/** + * Holder for parent node of current [DataProviderNode]. + * So, each [DataProviderNode] provides itself and read parent node. + * It allows building tree of [DataProviderNode] and traverse it later on. + * + * @see ModifierLocalModifierNode + */ +private val LocalDataProviderNode = modifierLocalOf { + null +} + +internal class DataProviderNode(var dataProvider: (dataId: String) -> Any?) : + Modifier.Node(), + ModifierLocalModifierNode, + FocusEventModifierNode { + + // TODO: should we use state here and in parent with children for thread safety? Will it trigger recompositions? + var hasFocus = false + + var parent: DataProviderNode? = null + + private val _children = mutableSetOf() + val children: Set = _children + + override val providedValues: ModifierLocalMap = modifierLocalMapOf(LocalDataProviderNode to this) + + override fun onAttach() { + val oldParent = parent + parent = LocalDataProviderNode.current + if (parent !== oldParent) { + oldParent?._children?.remove(this) + parent?._children?.add(this) + } + } + + override fun onDetach() { + parent?._children?.remove(this) + parent = null + } + + override fun onFocusEvent(focusState: FocusState) { + hasFocus = focusState.hasFocus + } + + fun updateParent() { + parent = LocalDataProviderNode.current + } +} diff --git a/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ProvideData.kt b/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ProvideData.kt new file mode 100644 index 000000000..1bc1805ab --- /dev/null +++ b/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ProvideData.kt @@ -0,0 +1,111 @@ +package org.jetbrains.jewel.bridge.actionSystem + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusEventModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DataProvider +import org.jetbrains.annotations.VisibleForTesting +import javax.swing.JComponent + +/** + * Layout composable that create the bridge between [Modifier.provideData] used inside [content] and + * [component]. So, IntelliJ's [DataManager] can use [component] as [DataProvider]. + * + * When IntelliJ requests [getData] from [component] Compose will traverse [DataProviderNode] hierarchy + * and collect it according [DataProvider] rules (see javadoc). + */ +// TODO: choose better naming? +@Suppress("unused", "FunctionName") +@Composable +fun ComponentDataProviderBridge( + component: JComponent, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val rootDataProviderModifier = remember { RootDataProviderModifier() } + + Box(modifier = Modifier.then(rootDataProviderModifier).then(modifier)) { + content() + } + + DisposableEffect(component) { + DataManager.registerDataProvider(component, rootDataProviderModifier.dataProvider) + + onDispose { + DataManager.removeDataProvider(component) + } + } +} + +/** + * Configure component to provide data for IntelliJ Actions system. + * + * Use this modifier to provide context related data that can be used by + * IntelliJ Actions functionality such as Search Everywhere, Action Popups etc. + * + * Important note: modifiers order is important, so be careful with order of [focusable] and [provideData] + * (see [FocusEventModifierNode]). + * + * @see DataProvider + * @see DataContext + * @see ComponentDataProviderBridge + */ +@Suppress("unused") +fun Modifier.provideData(dataProvider: (dataId: String) -> Any?) = this.then( + DataProviderElement(dataProvider), +) + +@VisibleForTesting +internal class RootDataProviderModifier : ModifierNodeElement() { + + private val rootNode = DataProviderNode { null } + + val dataProvider: (String) -> Any? = { + rootNode.getData(it) + } + + override fun create() = rootNode + + override fun update(node: DataProviderNode) { + // do nothing + } + + override fun hashCode(): Int = rootNode.hashCode() + + override fun equals(other: Any?) = other === this +} + +private fun DataProviderNode.getData(dataId: String): Any? { + val focusedNode = this.traverseDownToFocused() ?: return null + return focusedNode.collectData(dataId) +} + +private fun DataProviderNode.collectData(dataId: String): Any? { + var currentNode: DataProviderNode? = this + while (currentNode != null) { + val data = currentNode.dataProvider(dataId) + if (data != null) { + return data + } + currentNode = currentNode.parent + } + + return null +} + +private fun DataProviderNode.traverseDownToFocused(): DataProviderNode? { + for (child in children) { + if (child.hasFocus) { + return child.traverseDownToFocused() + } + } + + return this.takeIf { hasFocus } +} diff --git a/ide-laf-bridge/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ProvideDataTest.kt b/ide-laf-bridge/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ProvideDataTest.kt new file mode 100644 index 000000000..e3e078e8b --- /dev/null +++ b/ide-laf-bridge/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ProvideDataTest.kt @@ -0,0 +1,115 @@ +package org.jetbrains.jewel.bridge.actionSystem + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class ProvideDataTest { + + @JvmField + @Rule + val rule = createComposeRule() + + @Test + fun `one component`() { + runBlocking { + val rootDataProviderModifier = RootDataProviderModifier() + var focusManager: FocusManager? = null + rule.setContent { + focusManager = LocalFocusManager.current + Box( + modifier = Modifier + .then(rootDataProviderModifier) + .testTag("provider") + .provideData { + when (it) { + "data" -> "ok" + else -> null + } + } + .focusable(), + ) + } + rule.awaitIdle() + focusManager!!.moveFocus(FocusDirection.Next) + rule.awaitIdle() + + rule.onNodeWithTag("provider").assertIsFocused() + + assertEquals("ok", rootDataProviderModifier.dataProvider("data")) + assertEquals(null, rootDataProviderModifier.dataProvider("another_data")) + } + } + + @Test + fun `component hierarchy`() { + runBlocking { + val rootDataProviderModifier = RootDataProviderModifier() + var focusManager: FocusManager? = null + rule.setContent { + focusManager = LocalFocusManager.current + Box( + modifier = Modifier + .then(rootDataProviderModifier) + .testTag("root_provider") + .provideData { + when (it) { + "isRoot" -> "yes" + else -> null + } + } + .focusable(), + ) { + Box(modifier = Modifier.testTag("non_data_provider").focusable()) { + Box( + modifier = Modifier + .testTag("data_provider_item") + .provideData { + when (it) { + "data" -> "ok" + else -> null + } + }.focusable(), + ) + } + } + } + + rule.awaitIdle() + focusManager!!.moveFocus(FocusDirection.Next) + rule.awaitIdle() + + rule.onNodeWithTag("root_provider").assertIsFocused() + assertEquals("yes", rootDataProviderModifier.dataProvider("isRoot")) + assertEquals(null, rootDataProviderModifier.dataProvider("data")) + + focusManager!!.moveFocus(FocusDirection.Next) + rule.awaitIdle() + + rule.onNodeWithTag("non_data_provider").assertIsFocused() + // non_data_provider still should provide isRoot == true because it should be taken from root + // but shouldn't provide "data" yet + assertEquals("yes", rootDataProviderModifier.dataProvider("isRoot")) + assertEquals(null, rootDataProviderModifier.dataProvider("data")) + + focusManager!!.moveFocus(FocusDirection.Next) + rule.awaitIdle() + + rule.onNodeWithTag("data_provider_item").assertIsFocused() + + assertEquals("yes", rootDataProviderModifier.dataProvider("isRoot")) + assertEquals("ok", rootDataProviderModifier.dataProvider("data")) + } + } +}