[JEWEL-174] Fixed ToolWindow focus

- The `.trackActivation()` call on the Column modifier was overriding the component activation value
  - With modifier added, we would only track the activation IF the column (or any descendant) get the focus
- To fix the problem, I just removed the track activation to use the ToolWindow activation state
- Additionally, I've added a FocusRequester to focus on the item when we open the ToolWindow
  - This is similar to what is done in the ReleaseDemo
- Added click listener to recover focus in the compose window when clicking on it

closes https://github.com/JetBrains/intellij-community/pull/3228

GitOrigin-RevId: 554433974bae42126d493290d8a6bef67f9ea135
This commit is contained in:
Gustavo Fao Valvassori
2025-09-18 18:46:16 +00:00
committed by intellij-monorepo-bot
parent d6633fa925
commit 3caa4d60c7
6 changed files with 192 additions and 34 deletions
@@ -5,9 +5,13 @@ f:org.jetbrains.jewel.bridge.BridgeUtilsKt
- *sf:toNonNegativeDpSize(java.awt.Dimension):J
f:org.jetbrains.jewel.bridge.JewelComposePanelWrapperKt
- *sf:JewelComposeNoThemePanel(kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2):javax.swing.JComponent
- *sf:JewelComposeNoThemePanel(Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2):javax.swing.JComponent
- *bs:JewelComposeNoThemePanel$default(kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,I,java.lang.Object):javax.swing.JComponent
- *bs:JewelComposeNoThemePanel$default(Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,I,java.lang.Object):javax.swing.JComponent
- *sf:composeWithoutTheme(kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2):javax.swing.JComponent
- *sf:composeWithoutTheme(Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2):javax.swing.JComponent
- *bs:composeWithoutTheme$default(kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,I,java.lang.Object):javax.swing.JComponent
- *bs:composeWithoutTheme$default(Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,I,java.lang.Object):javax.swing.JComponent
- *sf:getLocalComponent():androidx.compose.runtime.ProvidableCompositionLocal
f:org.jetbrains.jewel.bridge.theme.IntUiBridgeTextKt
- *sf:retrieveDefaultTextStyle(F):androidx.compose.ui.text.TextStyle
@@ -71,12 +71,18 @@ f:org.jetbrains.jewel.bridge.JewelBridgeException$KeysNotFoundException
- <init>(java.util.List,java.lang.String):V
f:org.jetbrains.jewel.bridge.JewelComposePanelWrapperKt
- sf:JewelComposePanel(kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2):javax.swing.JComponent
- sf:JewelComposePanel(Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2):javax.swing.JComponent
- bs:JewelComposePanel$default(kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,I,java.lang.Object):javax.swing.JComponent
- bs:JewelComposePanel$default(Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,I,java.lang.Object):javax.swing.JComponent
- sf:compose(kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2):javax.swing.JComponent
- sf:compose(Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2):javax.swing.JComponent
- bs:compose$default(kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,I,java.lang.Object):javax.swing.JComponent
- bs:compose$default(Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,I,java.lang.Object):javax.swing.JComponent
f:org.jetbrains.jewel.bridge.ToolWindowExtensionsKt
- sf:addComposeTab(com.intellij.openapi.wm.ToolWindow,java.lang.String,Z,Z,kotlin.jvm.functions.Function3):V
- sf:addComposeTab(com.intellij.openapi.wm.ToolWindow,java.lang.String,Z,Z,Z,kotlin.jvm.functions.Function3):V
- bs:addComposeTab$default(com.intellij.openapi.wm.ToolWindow,java.lang.String,Z,Z,kotlin.jvm.functions.Function3,I,java.lang.Object):V
- bs:addComposeTab$default(com.intellij.openapi.wm.ToolWindow,java.lang.String,Z,Z,Z,kotlin.jvm.functions.Function3,I,java.lang.Object):V
org.jetbrains.jewel.bridge.ToolWindowScope
- a:getToolWindow():com.intellij.openapi.wm.ToolWindow
f:org.jetbrains.jewel.bridge.TypographyKt
@@ -7,11 +7,15 @@ import androidx.compose.ui.awt.ComposePanel
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.application.PathManager
import java.awt.BorderLayout
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.AWTEvent
import java.awt.Component
import java.awt.Toolkit
import java.awt.event.AWTEventListener
import java.awt.event.MouseEvent
import java.io.File
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingUtilities
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.jewel.bridge.actionSystem.ComponentDataProviderBridge
import org.jetbrains.jewel.bridge.component.JBPopupRenderer
@@ -27,11 +31,28 @@ import org.jetbrains.jewel.ui.util.LocalMessageResourceResolverProvider
*
* The [content] is wrapped in a [SwingBridgeTheme], which will be derived from the current Swing LaF.
*
* @param focusOnClickInside If `true`, the underlying [ComposePanel] will request focus when a mouse click occurs
* inside it, even if it does not hit a "focusable" element.
* @param config A lambda to configure the underlying [ComposePanel].
* @param content The Composable content to display.
*/
public fun compose(
focusOnClickInside: Boolean = true,
config: ComposePanel.() -> Unit = {},
content: @Composable () -> Unit,
): JComponent = JewelComposePanel(focusOnClickInside, config, content)
/**
* Creates a Swing component that can host Compose content.
*
* The [content] is wrapped in a [SwingBridgeTheme], which will be derived from the current Swing LaF.
*
* @param config A lambda to configure the underlying [ComposePanel].
* @param content The Composable content to display.
*/
@Deprecated("Use the version with 'focusOnClickInside' parameter")
public fun compose(config: ComposePanel.() -> Unit = {}, content: @Composable () -> Unit): JComponent =
JewelComposePanel(config, content)
JewelComposePanel(focusOnClickInside = false, config, content)
/**
* Creates a Swing component that can host Compose content.
@@ -40,12 +61,18 @@ public fun compose(config: ComposePanel.() -> Unit = {}, content: @Composable ()
*
* This is the same as [compose].
*
* @param focusOnClickInside If `true`, the underlying [ComposePanel] will request focus when a mouse click occurs
* inside it, even if it does not hit a "focusable" element.
* @param config A lambda to configure the underlying [ComposePanel].
* @param content The Composable content to display.
*/
@Suppress("ktlint:standard:function-naming", "FunctionName") // Swing to Compose bridge API
public fun JewelComposePanel(config: ComposePanel.() -> Unit = {}, content: @Composable () -> Unit): JComponent =
createJewelComposePanel { jewelPanel ->
public fun JewelComposePanel(
focusOnClickInside: Boolean = true,
config: ComposePanel.() -> Unit = {},
content: @Composable () -> Unit,
): JComponent =
createJewelComposePanel(focusOnClickInside) { jewelPanel ->
config()
setContent {
SwingBridgeTheme {
@@ -59,6 +86,41 @@ public fun JewelComposePanel(config: ComposePanel.() -> Unit = {}, content: @Com
}
}
/**
* Creates a Swing component that can host Compose content.
*
* The [content] is wrapped in a [SwingBridgeTheme], which will be derived from the current Swing LaF.
*
* This is the same as [compose].
*
* @param config A lambda to configure the underlying [ComposePanel].
* @param content The Composable content to display.
*/
@Suppress("ktlint:standard:function-naming", "FunctionName") // Swing to Compose bridge API
@Deprecated("Use the version with 'focusOnClickInside' parameter")
public fun JewelComposePanel(config: ComposePanel.() -> Unit = {}, content: @Composable () -> Unit): JComponent =
JewelComposePanel(focusOnClickInside = false, config, content)
/**
* Creates a Swing component that can host Compose content.
*
* The [content] is **not** wrapped in a theme, meaning that you **MUST** wrap the content in a theme by yourself.
*
* This is not normally what you want; use this only if you want to provide a completely custom theme.
*
* @param focusOnClickInside If `true`, the underlying [ComposePanel] will request focus when a mouse click occurs
* inside it, even if it does not hit a "focusable" element.
* @param config A lambda to configure the underlying [ComposePanel].
* @param content The Composable content to display.
*/
@ApiStatus.Experimental
@ExperimentalJewelApi
public fun composeWithoutTheme(
focusOnClickInside: Boolean = true,
config: ComposePanel.() -> Unit = {},
content: @Composable () -> Unit,
): JComponent = JewelComposeNoThemePanel(focusOnClickInside, config, content)
/**
* Creates a Swing component that can host Compose content.
*
@@ -72,8 +134,44 @@ public fun JewelComposePanel(config: ComposePanel.() -> Unit = {}, content: @Com
@ApiStatus.Experimental
@ExperimentalJewelApi
@Suppress("ktlint:standard:function-naming") // Swing to Compose bridge API
@Deprecated("Use the version with 'focusOnClickInside' parameter")
public fun composeWithoutTheme(config: ComposePanel.() -> Unit = {}, content: @Composable () -> Unit): JComponent =
JewelComposeNoThemePanel(config, content)
JewelComposeNoThemePanel(focusOnClickInside = false, config, content)
/**
* Creates a Swing component that can host Compose content.
*
* The [content] is **not** wrapped in a theme, meaning that you **MUST** wrap the content in a theme by yourself.
*
* This is not normally what you want; use this only if you want to provide a completely custom theme.
*
* This is the same as [composeWithoutTheme].
*
* @param focusOnClickInside If `true`, the underlying [ComposePanel] will request focus when a mouse click occurs
* inside it, even if it does not hit a "focusable" element.
* @param config A lambda to configure the underlying [ComposePanel].
* @param content The Composable content to display.
*/
@ApiStatus.Experimental
@ExperimentalJewelApi
@Suppress("ktlint:standard:function-naming", "FunctionName") // Swing to Compose bridge API
public fun JewelComposeNoThemePanel(
focusOnClickInside: Boolean = true,
config: ComposePanel.() -> Unit = {},
content: @Composable () -> Unit,
): JComponent =
createJewelComposePanel(focusOnClickInside) { jewelPanel ->
config()
setContent {
CompositionLocalProvider(
LocalComponentFoundation provides this@createJewelComposePanel,
LocalPopupRenderer provides JBPopupRenderer,
LocalMessageResourceResolverProvider provides BridgeMessageResourceResolver(),
) {
ComponentDataProviderBridge(jewelPanel, content = content)
}
}
}
/**
* Creates a Swing component that can host Compose content.
@@ -90,21 +188,14 @@ public fun composeWithoutTheme(config: ComposePanel.() -> Unit = {}, content: @C
@ApiStatus.Experimental
@ExperimentalJewelApi
@Suppress("ktlint:standard:function-naming", "FunctionName") // Swing to Compose bridge API
@Deprecated("Use the version with 'focusOnClickInside' parameter")
public fun JewelComposeNoThemePanel(config: ComposePanel.() -> Unit = {}, content: @Composable () -> Unit): JComponent =
createJewelComposePanel { jewelPanel ->
config()
setContent {
CompositionLocalProvider(
LocalComponentFoundation provides this@createJewelComposePanel,
LocalPopupRenderer provides JBPopupRenderer,
LocalMessageResourceResolverProvider provides BridgeMessageResourceResolver(),
) {
ComponentDataProviderBridge(jewelPanel, content = content)
}
}
}
JewelComposeNoThemePanel(focusOnClickInside = false, config, content)
private fun createJewelComposePanel(config: ComposePanel.(JewelComposePanelWrapper) -> Unit): JewelComposePanelWrapper {
private fun createJewelComposePanel(
focusOnClickInside: Boolean,
config: ComposePanel.(JewelComposePanelWrapper) -> Unit,
): JewelComposePanelWrapper {
if (System.getProperty("skiko.library.path") == null) {
val bundledSkikoFolder = File(PathManager.getLibPath(), "/skiko-awt-runtime-all")
if (bundledSkikoFolder.isDirectory && bundledSkikoFolder.canRead()) {
@@ -113,22 +204,26 @@ private fun createJewelComposePanel(config: ComposePanel.(JewelComposePanelWrapp
JewelLogger.getInstance("SkikoLoader").warn("Bundled Skiko not found/not readable, falling back to default")
}
}
val jewelPanel = JewelComposePanelWrapper()
jewelPanel.layout = BorderLayout()
val composePanel = ComposePanel()
jewelPanel.add(composePanel, BorderLayout.CENTER)
composePanel.config(jewelPanel)
val jewelPanel = JewelComposePanelWrapper(focusOnClickInside)
jewelPanel.composePanel.config(jewelPanel)
ComposeUiInspector(jewelPanel)
return jewelPanel
}
internal class JewelComposePanelWrapper : JPanel(), UiDataProvider {
internal 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
if (!isFocusOwner && SwingUtilities.isDescendingFrom(event.component, this)) {
composePanel.requestFocus()
}
}
val composePanel: ComposePanel
get() =
components.singleOrNull() as? ComposePanel
?: error("JewelComposePanelWrapper was not initialized with a ComposePanel")
val composePanel: ComposePanel = ComposePanel()
init {
super.addToCenter(composePanel)
}
override fun addImpl(comp: Component, constraints: Any?, index: Int) {
require(components.isEmpty()) {
@@ -142,6 +237,20 @@ internal class JewelComposePanelWrapper : JPanel(), UiDataProvider {
super.addImpl(comp, constraints, index)
}
override fun addNotify() {
super.addNotify()
if (focusOnClickInside) {
Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK)
}
}
override fun removeNotify() {
super.removeNotify()
if (focusOnClickInside) {
Toolkit.getDefaultToolkit().removeAWTEventListener(listener)
}
}
override fun uiDataSnapshot(sink: DataSink) {
targetProvider?.uiDataSnapshot(sink)
}
@@ -8,6 +8,8 @@ import org.jetbrains.jewel.foundation.enableNewSwingCompositing
/**
* Adds a new tab to the tool window with Compose content.
*
* @param focusOnClickInside If `true`, the underlying [ComposePanel] will request focus when a mouse click occurs
* inside it, even if it does not hit a "focusable" element.
* @param tabDisplayName The title of the tab.
* @param isLockable Whether the tab can be locked.
* @param isCloseable Whether the tab can be closed.
@@ -17,6 +19,7 @@ public fun ToolWindow.addComposeTab(
@TabTitle tabDisplayName: String? = null,
isLockable: Boolean = true,
isCloseable: Boolean = false,
focusOnClickInside: Boolean = true,
content: @Composable ToolWindowScope.() -> Unit,
) {
// We need to make sure this is done before Compose is attached.
@@ -25,7 +28,7 @@ public fun ToolWindow.addComposeTab(
val tabContent =
contentManager.factory.createContent(
JewelComposePanel {
JewelComposePanel(focusOnClickInside) {
val scope =
object : ToolWindowScope {
override val toolWindow: ToolWindow
@@ -40,6 +43,24 @@ public fun ToolWindow.addComposeTab(
contentManager.addContent(tabContent)
}
/**
* Adds a new tab to the tool window with Compose content.
*
* @param tabDisplayName The title of the tab.
* @param isLockable Whether the tab can be locked.
* @param isCloseable Whether the tab can be closed.
* @param content The Composable content of the tab.
*/
@Deprecated("Use the version with 'focusOnClickInside' parameter")
public fun ToolWindow.addComposeTab(
@TabTitle tabDisplayName: String? = null,
isLockable: Boolean = true,
isCloseable: Boolean = false,
content: @Composable ToolWindowScope.() -> Unit,
) {
addComposeTab(tabDisplayName, isLockable, isCloseable, focusOnClickInside = true, content)
}
/** A scope for the content of a tool window tab. */
public interface ToolWindowScope {
/** The tool window in which the tab is displayed. */
@@ -12,7 +12,10 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onFirstVisible
import androidx.compose.ui.unit.dp
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.project.Project
@@ -63,7 +66,7 @@ internal fun ComponentShowcaseTab(project: Project) {
@Composable
private fun RowScope.ColumnOne() {
Column(Modifier.trackActivation().weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
var activated by remember { mutableStateOf(false) }
Text(
"Here is a selection of our finest components(activated: $activated):",
@@ -72,10 +75,16 @@ private fun RowScope.ColumnOne() {
)
var selectedItem by remember { mutableIntStateOf(-1) }
val focusRequester = remember { FocusRequester() }
ListComboBox(
items = remember { listOf("Hello", "World") },
selectedIndex = selectedItem,
onSelectedItemChange = { selectedItem = it },
modifier =
Modifier
.focusRequester(focusRequester)
.onFirstVisible { focusRequester.requestFocus() },
)
ListComboBox(
items = remember { listOf("Hello", "World") },
@@ -16,13 +16,22 @@ internal class JewelDemoToolWindowFactory : ToolWindowFactory, DumbAware {
// Enable custom popup rendering to use JBPopup instead of the default Compose implementation
JewelFlags.useCustomPopupRenderer = true
toolWindow.addComposeTab(DevkitComposeBundle.message("jewel.tw.tab.title.components")) { ComponentShowcaseTab(project) }
toolWindow.addComposeTab(
DevkitComposeBundle.message("jewel.tw.tab.title.components"),
focusOnClickInside = true,
) { ComponentShowcaseTab(project) }
toolWindow.addComposeTab(DevkitComposeBundle.message("jewel.tw.tab.title.releases.demo")) { ReleasesSampleCompose(project) }
toolWindow.addComposeTab(
DevkitComposeBundle.message("jewel.tw.tab.title.releases.demo"),
focusOnClickInside = true,
) { ReleasesSampleCompose(project) }
toolWindow.addSwingTab(SwingComparisonTabPanel(), DevkitComposeBundle.message("jewel.tw.tab.title.swing.comparison"))
toolWindow.addComposeTab(DevkitComposeBundle.message("jewel.tw.tab.title.scrollbars.sample")) { ScrollbarsShowcaseTab() }
toolWindow.addComposeTab(
DevkitComposeBundle.message("jewel.tw.tab.title.scrollbars.sample"),
focusOnClickInside = true,
) { ScrollbarsShowcaseTab() }
}
private fun ToolWindow.addSwingTab(component: JComponent, @TabTitle title: String) {