Skip to content

Commit

Permalink
Introduce DataProvider support (#110)
Browse files Browse the repository at this point in the history
* Introduce DataProvider support

* Code style

* Code style

* Fix detektTest issue with Unit type

(cherry picked from commit 8c9f591)
  • Loading branch information
Walingar authored and jakub-senohrabek committed Sep 10, 2024
1 parent 642ace1 commit 802d439
Show file tree
Hide file tree
Showing 4 changed files with 308 additions and 0 deletions.
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()
}
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
}
}
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 }
}
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"))
}
}
}

0 comments on commit 802d439

Please sign in to comment.