-
Notifications
You must be signed in to change notification settings - Fork 42
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Introduce DataProvider support (#110)
* Introduce DataProvider support * Code style * Code style * Fix detektTest issue with Unit type (cherry picked from commit 8c9f591)
- Loading branch information
1 parent
642ace1
commit 802d439
Showing
4 changed files
with
308 additions
and
0 deletions.
There are no files selected for viewing
24 changes: 24 additions & 0 deletions
24
...laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/DataProviderElement.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<DataProviderNode>() { | ||
|
||
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() | ||
} |
58 changes: 58 additions & 0 deletions
58
ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/DataProviderNode.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<DataProviderNode?> { | ||
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<DataProviderNode>() | ||
val children: Set<DataProviderNode> = _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 | ||
} | ||
} |
111 changes: 111 additions & 0 deletions
111
ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ProvideData.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<DataProviderNode>() { | ||
|
||
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 } | ||
} |
115 changes: 115 additions & 0 deletions
115
ide-laf-bridge/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ProvideDataTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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")) | ||
} | ||
} | ||
} |