Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce DataProvider support #110

Merged
merged 4 commits into from
Sep 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ide-laf-bridge/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ plugins {
dependencies {
api(projects.themes.intUi.intUiStandalone)
compileOnly(libs.bundles.idea)

testImplementation(compose.desktop.uiTestJUnit4)
}
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?) :
Walingar marked this conversation as resolved.
Show resolved Hide resolved
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(
rock3r marked this conversation as resolved.
Show resolved Hide resolved
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"))
}
}
}