[JEWEL-1396] Keep Escape in Jewel ComboBox popups

Handle Escape in a speed-search ComboBox before it reaches the popup, so the
first press clears the query and the second closes the popup. Route native
key events by popup ownership, while still forwarding owner-window events for
focusable popups. Avoid renderer dismissal callbacks when speed-search focus
loss dismissal is disabled, and make non-native popup renderers explicitly
ignore window shapes. Add renderer contract and headful Spectre coverage.

 Conflicts:
	platform/jewel/int-ui/int-ui-standalone-tests/build.gradle.kts

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


(cherry picked from commit 75292aa91e3903a659f914f03c7d20f92c1da906)

IJ-MR-220560

GitOrigin-RevId: ee54893658771cfec864854f2c0cc00171f007b8
This commit is contained in:
Sebastiano Poggi
2026-09-07 22:29:10 +00:00
committed by intellij-monorepo-bot
parent 45bb50bd91
commit 6e788f989a
12 changed files with 544 additions and 22 deletions
+3
View File
@@ -22,6 +22,7 @@ ktfmtGradlePlugin = "0.23.0"
metalava = "1.0.0-alpha13"
jsoup = "1.21.2"
mockk = "1.14.5"
spectre = "0.5.0"
[libraries]
assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertJ"}
@@ -52,6 +53,8 @@ ktor-client-java = { module = "io.ktor:ktor-client-java", version = "3.0.3" }
metalava = { module = "com.android.tools.metalava:metalava", version.ref = "metalava" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
spectre-core = { module = "dev.sebastiano.spectre:spectre-core", version.ref = "spectre" }
spectre-testing = { module = "dev.sebastiano.spectre:spectre-testing", version.ref = "spectre" }
# Plugin libraries for build-logic's convention plugins to use to resolve the types/tasks coming from these plugins
detekt-gradlePlugin = { module = "dev.detekt:detekt-gradle-plugin", version.ref = "detekt" }
@@ -71,6 +71,30 @@ internal object JBPopupRenderer : PopupRenderer {
cornerSize: CornerSize,
content: @Composable () -> Unit,
) {
Popup(
popupPositionProvider = popupPositionProvider,
properties = properties,
onDismissRequest = onDismissRequest,
onPreviewKeyEvent = onPreviewKeyEvent,
onKeyEvent = onKeyEvent,
cornerSize = cornerSize,
windowShape = null,
content = content,
)
}
@Composable
override fun Popup(
popupPositionProvider: PopupPositionProvider,
properties: PopupProperties,
onDismissRequest: (() -> Unit)?,
onPreviewKeyEvent: ((KeyEvent) -> Boolean)?,
onKeyEvent: ((KeyEvent) -> Boolean)?,
cornerSize: CornerSize,
windowShape: ((IntSize) -> java.awt.Shape)?,
content: @Composable () -> Unit,
) {
// JBPopup does not expose native window shaping, so windowShape is ignored.
JBPopup(
popupPositionProvider = popupPositionProvider,
onDismissRequest = onDismissRequest,
@@ -4,9 +4,13 @@ plugins {
alias(libs.plugins.compose.compiler)
}
val spectreTest = sourceSets.create("spectreTest") { java.srcDir("src/spectreTest/kotlin") }
dependencies {
api(projects.intUi.intUiStandalone)
testImplementation(projects.foundation)
testImplementation(projects.ui)
testImplementation(kotlin("test"))
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.engine)
@@ -15,6 +19,35 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(compose.desktop.currentOs) { exclude(group = "org.jetbrains.compose.material") }
testImplementation(libs.jna.core)
"spectreTestImplementation"(projects.foundation)
"spectreTestImplementation"(projects.ui)
"spectreTestImplementation"(projects.intUi.intUiStandalone)
"spectreTestImplementation"(compose.desktop.currentOs) { exclude(group = "org.jetbrains.compose.material") }
"spectreTestImplementation"(libs.spectre.core)
"spectreTestImplementation"(libs.spectre.testing)
"spectreTestImplementation"(kotlin("test"))
"spectreTestImplementation"(libs.junit.jupiter)
"spectreTestImplementation"(libs.kotlinx.coroutines.core)
"spectreTestRuntimeOnly"(libs.junit.platform.engine)
"spectreTestRuntimeOnly"(libs.junit.platform.launcher)
}
tasks.test { useJUnitPlatform() }
val jdkLevel = project.property("jdk.level") as String
tasks {
named<Test>("test") { useJUnitPlatform() }
register<Test>("spectreTest") {
group = "verification"
description = "Runs opt-in headful Compose Desktop UI tests with Spectre."
testClassesDirs = spectreTest.output.classesDirs
classpath = spectreTest.runtimeClasspath
useJUnitPlatform()
maxParallelForks = 1
javaLauncher = project.javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(jdkLevel) }
systemProperty("apple.awt.UIElement", "true")
systemProperty("java.awt.headless", "false")
systemProperty("jewel.customPopupRender", "true")
}
}
@@ -0,0 +1,212 @@
@file:OptIn(ExperimentalJewelApi::class)
package org.jetbrains.jewel.intui.standalone.popup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.ComposeWindow
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.application
import dev.sebastiano.spectre.core.ComposeAutomator
import dev.sebastiano.spectre.core.RobotDriver
import dev.sebastiano.spectre.testing.runSpectreTest
import java.awt.event.KeyEvent
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
import kotlin.test.assertTrue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
import org.jetbrains.jewel.foundation.JewelFlags
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
import org.jetbrains.jewel.intui.standalone.window.Window as JewelWindow
import org.jetbrains.jewel.ui.component.ComboBox
import org.jetbrains.jewel.ui.component.DefaultButton
import org.jetbrains.jewel.ui.component.PopupManager
import org.jetbrains.jewel.ui.component.PopupMenu
import org.jetbrains.jewel.ui.component.SpeedSearchArea
import org.jetbrains.jewel.ui.component.Text
import org.jetbrains.jewel.ui.component.rememberSpeedSearchState
import org.jetbrains.jewel.ui.component.search.SpeedSearchableComboBox
import org.junit.jupiter.api.Test
// Note: these tests run intentionally Gradle-only until JEWEL-1390
class CustomPopupRendererSpectreTest {
@Test
fun `escape closes a hovered combo box`(): Unit = runSpectreTestWithCustomPopupRenderer {
val app = SpectreTestApplication()
app.start()
try {
val automator = ComposeAutomator.inProcess(RobotDriver.synthetic(app.awaitWindow()))
automator.click(automator.waitForNode(tag = REGULAR_COMBO_TAG))
automator.waitForNode(tag = REGULAR_POPUP_VISIBLE_TAG, text = "true")
// The pointer remains inside the ComboBox after click, reproducing the hover path that
// previously let the popup's JDialogRenderer consume Escape as a no-op dismissal.
automator.pressKey(KeyEvent.VK_ESCAPE)
automator.waitForNode(tag = REGULAR_POPUP_VISIBLE_TAG, text = "false")
} finally {
app.stop()
}
}
@Test
fun `escape closes a focusable menu when the owner window receives the key`(): Unit =
runSpectreTestWithCustomPopupRenderer {
val app = SpectreTestApplication()
app.start()
try {
val automator = ComposeAutomator.inProcess(RobotDriver.synthetic(app.awaitWindow()))
val menuButton = automator.waitForNode(tag = MENU_BUTTON_TAG)
automator.click(menuButton)
automator.waitForNode(tag = MENU_VISIBLE_TAG, text = "true")
// Spectre routes synthetic key events to the window under its injected pointer. Move that pointer back
// to the owner window to cover focusable popups that have not taken native focus yet.
automator.moveTo(menuButton)
automator.waitForIdle()
automator.pressKey(KeyEvent.VK_ESCAPE)
automator.waitForNode(tag = MENU_VISIBLE_TAG, text = "false")
} finally {
app.stop()
}
}
@Test
fun `escape prioritizes speed search when dismiss on lose focus is disabled`(): Unit =
runSpectreTestWithCustomPopupRenderer {
val app = SpectreTestApplication()
app.start()
try {
val automator = ComposeAutomator.inProcess(RobotDriver.synthetic(app.awaitWindow()))
automator.click(automator.waitForNode(tag = SPEED_SEARCH_COMBO_TAG))
automator.waitForNode(tag = SPEED_SEARCH_POPUP_VISIBLE_TAG, text = "true")
automator.typeText("Alpha")
automator.waitForNode(tag = SPEED_SEARCH_QUERY_TAG, text = "Alpha")
automator.waitForNode(tag = SPEED_SEARCH_VISIBLE_TAG, text = "true")
automator.pressKey(KeyEvent.VK_ESCAPE)
automator.waitForNode(tag = SPEED_SEARCH_VISIBLE_TAG, text = "false")
automator.waitForNode(tag = SPEED_SEARCH_POPUP_VISIBLE_TAG, text = "true")
automator.pressKey(KeyEvent.VK_ESCAPE)
automator.waitForNode(tag = SPEED_SEARCH_POPUP_VISIBLE_TAG, text = "false")
} finally {
app.stop()
}
}
}
private fun runSpectreTestWithCustomPopupRenderer(block: suspend CoroutineScope.() -> Unit): Unit = runSpectreTest {
assertTrue(JewelFlags.useCustomPopupRenderer, "spectreTest must enable JDialogRenderer")
block()
}
@Composable
private fun PopupEscapeScreen() {
val regularPopupManager = remember { PopupManager() }
var menuVisible by remember { mutableStateOf(false) }
var speedSearchPopupVisible by remember { mutableStateOf(false) }
val speedSearchState = rememberSpeedSearchState()
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = regularPopupManager.isPopupVisible.value.toString(),
modifier = Modifier.testTag(REGULAR_POPUP_VISIBLE_TAG),
)
ComboBox(
labelText = "Regular ComboBox",
modifier = Modifier.testTag(REGULAR_COMBO_TAG).width(240.dp),
popupManager = regularPopupManager,
) {
Text("Regular popup content")
}
Text(text = menuVisible.toString(), modifier = Modifier.testTag(MENU_VISIBLE_TAG))
Box {
DefaultButton(onClick = { menuVisible = true }, modifier = Modifier.testTag(MENU_BUTTON_TAG)) {
Text("Show menu")
}
if (menuVisible) {
PopupMenu(
onDismissRequest = {
menuVisible = false
true
},
horizontalAlignment = Alignment.Start,
) {
selectableItem(selected = false, onClick = {}) { Text("Menu item") }
}
}
}
Text(text = speedSearchPopupVisible.toString(), modifier = Modifier.testTag(SPEED_SEARCH_POPUP_VISIBLE_TAG))
Text(text = speedSearchState.isVisible.toString(), modifier = Modifier.testTag(SPEED_SEARCH_VISIBLE_TAG))
Text(text = speedSearchState.searchText, modifier = Modifier.testTag(SPEED_SEARCH_QUERY_TAG))
SpeedSearchArea(state = speedSearchState, dismissOnLoseFocus = false) {
SpeedSearchableComboBox(
items = listOf("Alpha", "Beta", "Gamma"),
selectedIndex = 0,
onSelectedItemChange = {},
modifier = Modifier.testTag(SPEED_SEARCH_COMBO_TAG).width(240.dp),
onPopupVisibleChange = { speedSearchPopupVisible = it },
)
}
}
}
private class SpectreTestApplication(private val content: @Composable () -> Unit = { PopupEscapeScreen() }) {
private val exitApplication = AtomicReference<(() -> Unit)?>(null)
private val window = AtomicReference<ComposeWindow?>(null)
fun start() {
thread(name = "spectre-popup-test-window", isDaemon = true) {
application(exitProcessOnExit = false) {
exitApplication.set(::exitApplication)
JewelWindow(onCloseRequest = ::exitApplication, title = "Jewel Spectre popup test") {
this@SpectreTestApplication.window.compareAndSet(null, window)
IntUiTheme { content() }
}
}
}
}
fun stop() {
exitApplication.get()?.invoke()
}
suspend fun awaitWindow(): ComposeWindow {
repeat(100) {
window.get()?.let {
return it
}
delay(100)
}
error("The Compose test window was not created")
}
}
private const val REGULAR_COMBO_TAG = "spectre.regularCombo"
private const val REGULAR_POPUP_VISIBLE_TAG = "spectre.regularPopupVisible"
private const val MENU_BUTTON_TAG = "spectre.menuButton"
private const val MENU_VISIBLE_TAG = "spectre.menuVisible"
private const val SPEED_SEARCH_COMBO_TAG = "spectre.speedSearchCombo"
private const val SPEED_SEARCH_POPUP_VISIBLE_TAG = "spectre.speedSearchPopupVisible"
private const val SPEED_SEARCH_VISIBLE_TAG = "spectre.speedSearchVisible"
private const val SPEED_SEARCH_QUERY_TAG = "spectre.speedSearchQuery"
@@ -418,16 +418,25 @@ private fun JPopupImpl(
}
}
is AWTKeyEvent -> {
if (!dialog.isVisible) return@AWTEventListener
val eventWindow = SwingUtilities.getWindowAncestor(event.component) ?: event.component as? Window
// Focusable popups do not necessarily own native focus immediately (see the JEWEL-1276
// focus-stealing workaround below), so their key events can arrive from the owner window.
val ownsEvent = eventWindow == dialog || (currentProperties.focusable && eventWindow == window)
if (event.isConsumed || !dialog.isVisible || !ownsEvent) return@AWTEventListener
val composeEvent = event.toComposeKeyEvent()
val consumed =
currentOnPreviewKeyEvent?.invoke(composeEvent) == true ||
currentOnKeyEvent?.invoke(composeEvent) == true
val dismissRequest = currentOnDismissRequest
val dismissed = !consumed && composeEvent.isDismissRequest() && dismissRequest != null
if (!consumed && composeEvent.isDismissRequest()) {
currentOnDismissRequest?.invoke()
if (dismissed) {
dismissRequest.invoke()
}
if (consumed || dismissed) {
event.consume()
}
}
}
@@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
@@ -18,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -34,6 +37,8 @@ import org.jetbrains.jewel.foundation.lazy.tree.rememberTreeState
import org.jetbrains.jewel.foundation.search.filter
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.foundation.util.JewelLogger
import org.jetbrains.jewel.ui.Orientation
import org.jetbrains.jewel.ui.component.Divider
import org.jetbrains.jewel.ui.component.GroupHeader
import org.jetbrains.jewel.ui.component.InlineWarningBanner
import org.jetbrains.jewel.ui.component.SimpleListItem
@@ -41,6 +46,7 @@ import org.jetbrains.jewel.ui.component.SpeedSearchArea
import org.jetbrains.jewel.ui.component.Text
import org.jetbrains.jewel.ui.component.VerticalScrollbar
import org.jetbrains.jewel.ui.component.rememberSpeedSearchState
import org.jetbrains.jewel.ui.component.search.SpeedSearchableComboBox
import org.jetbrains.jewel.ui.component.search.SpeedSearchableLazyColumn
import org.jetbrains.jewel.ui.component.search.SpeedSearchableTree
import org.jetbrains.jewel.ui.component.search.highlightSpeedSearchMatches
@@ -62,8 +68,15 @@ internal fun SpeedSearches(modifier: Modifier = Modifier) {
Modifier.widthIn(max = 200.dp).weight(1f, fill = false).semantics { isTraversalGroup = true },
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
GroupHeader(text = "Combo box", modifier = Modifier.fillMaxWidth())
SpeedSearchComboBoxExample()
Spacer(Modifier.height(8.dp))
Divider(Orientation.Horizontal, Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
GroupHeader(text = "Tree", modifier = Modifier.fillMaxWidth())
SpeedSearchTreeExample()
SpeedSearchTreeExample(Modifier.weight(1f))
}
Column(
@@ -85,6 +98,20 @@ internal fun SpeedSearches(modifier: Modifier = Modifier) {
}
}
@Composable
private fun SpeedSearchComboBoxExample(modifier: Modifier = Modifier) {
var selectedIndex by remember { mutableIntStateOf(0) }
SpeedSearchArea(modifier) {
SpeedSearchableComboBox(
items = TEST_LIST,
selectedIndex = selectedIndex,
onSelectedItemChange = { selectedIndex = it },
modifier = Modifier.fillMaxWidth(),
)
}
}
/**
* Demonstrates speed search functionality in a tree structure.
*
@@ -2,12 +2,18 @@
package org.jetbrains.jewel.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertHeightIsEqualTo
@@ -21,8 +27,10 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.size
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
import org.jetbrains.jewel.ui.component.interactions.performKeyPress
import org.junit.Rule
import org.junit.Test
@@ -35,6 +43,38 @@ class ComboBoxTest {
private val comboBox: SemanticsNodeInteraction
get() = composeRule.onNodeWithTag("ComboBox")
@Test
fun `escape closes popup without propagating to parent`() {
var receivedEscape = false
composeRule.setContent {
IntUiTheme {
Box(
modifier =
Modifier.onKeyEvent {
if (it.type == KeyEventType.KeyDown && it.key == Key.Escape) {
receivedEscape = true
}
false
}
) {
ComboBox(
labelText = "Just a label",
modifier = Modifier.testTag("ComboBox"),
popupContent = { Text("Popup content") },
)
}
}
}
comboBox.performClick()
popupMenu.assertIsDisplayed()
comboBox.performKeyPress(Key.Escape, rule = composeRule)
popupMenu.assertDoesNotExist()
assertFalse(receivedEscape)
}
@Test
fun `popup width can be bigger than combo box width when nothing is set`() {
composeRule.setContent {
@@ -0,0 +1,106 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.jewel.ui.component
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
import org.jetbrains.jewel.foundation.JewelFlags
import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalJewelApi::class)
class SpeedSearchAreaDismissRequestTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun `passes null dismiss request when dismiss on lose focus is disabled`() {
val renderer = recordSpeedSearchPopup(dismissOnLoseFocus = false)
assertTrue(renderer.dismissRequests.isNotEmpty())
assertNull(renderer.dismissRequests.last())
}
@Test
fun `passes dismiss request when dismiss on lose focus is enabled`() {
val renderer = recordSpeedSearchPopup(dismissOnLoseFocus = true)
assertTrue(renderer.dismissRequests.any { it != null })
}
private fun recordSpeedSearchPopup(dismissOnLoseFocus: Boolean): RecordingPopupRenderer {
val renderer = RecordingPopupRenderer()
var speedSearchState: SpeedSearchState? = null
val oldUseCustomPopupRenderer = JewelFlags.useCustomPopupRenderer
JewelFlags.useCustomPopupRenderer = true
try {
composeRule.setContent {
IntUiTheme {
CompositionLocalProvider(LocalPopupRenderer provides renderer) {
val state = rememberSpeedSearchState()
speedSearchState = state
SpeedSearchArea(state = state, dismissOnLoseFocus = dismissOnLoseFocus) {
Text("Searchable content")
}
}
}
}
composeRule.runOnIdle { speedSearchState?.isVisible = true }
composeRule.waitForIdle()
} finally {
JewelFlags.useCustomPopupRenderer = oldUseCustomPopupRenderer
}
return renderer
}
}
private class RecordingPopupRenderer : PopupRenderer {
val dismissRequests = mutableListOf<(() -> Unit)?>()
@Suppress("OVERRIDE_DEPRECATION")
@Composable
override fun Popup(
popupPositionProvider: PopupPositionProvider,
properties: PopupProperties,
onDismissRequest: (() -> Unit)?,
onPreviewKeyEvent: ((KeyEvent) -> Boolean)?,
onKeyEvent: ((KeyEvent) -> Boolean)?,
cornerSize: CornerSize,
content: @Composable () -> Unit,
) {
Popup(
popupPositionProvider = popupPositionProvider,
properties = properties,
onDismissRequest = onDismissRequest,
onPreviewKeyEvent = onPreviewKeyEvent,
onKeyEvent = onKeyEvent,
cornerSize = cornerSize,
windowShape = null,
content = content,
)
}
@Composable
override fun Popup(
popupPositionProvider: PopupPositionProvider,
properties: PopupProperties,
onDismissRequest: (() -> Unit)?,
onPreviewKeyEvent: ((KeyEvent) -> Boolean)?,
onKeyEvent: ((KeyEvent) -> Boolean)?,
cornerSize: CornerSize,
windowShape: ((IntSize) -> java.awt.Shape)?,
content: @Composable () -> Unit,
) {
dismissRequests += onDismissRequest
content()
}
}
@@ -77,7 +77,7 @@ class SpeedSearchableComboBoxTest {
}
@Test
fun `should hide on esc press`() = runComposeTest {
fun `should hide speed search before closing the popup on esc press`() = runComposeTest {
comboBox.performClick()
comboBox.performKeyPress("Item", rule = this)
@@ -85,6 +85,10 @@ class SpeedSearchableComboBoxTest {
comboBox.performKeyPress(Key.Escape, rule = this)
onSpeedSearchAreaInput.assertDoesNotExist()
onNodeWithTag("Jewel.ComboBox.Popup").assertIsDisplayed()
comboBox.performKeyPress(Key.Escape, rule = this)
onNodeWithTag("Jewel.ComboBox.Popup").assertDoesNotExist()
}
@Test
@@ -27,6 +27,7 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.Popup as ComposePopup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import java.awt.Shape
import org.jetbrains.jewel.foundation.JewelFlags
/**
@@ -89,10 +90,10 @@ public fun Popup(
* consume the event.
* @param onKeyEvent Callback invoked for key events after they are dispatched to children. Return `true` to consume the
* event.
* @param windowShape An optional factory that produces the [java.awt.Shape] used to clip the native popup window. The
* lambda receives the window's measured size in AWT logical units and **must** return a shape in the same coordinate
* system. Only applied by JDialogRenderer when `useCustomPopupRenderer = true` and the panel is not transparent (be
* it by enabling `compose.interop.blending` or setting the alpha channel of the panel's background to 0); all other
* @param windowShape An optional factory that produces the [Shape] used to clip the native popup window. The lambda
* receives the window's measured size in AWT logical units and **must** return a shape in the same coordinate system.
* Only applied by JDialogRenderer when `useCustomPopupRenderer = true` and the panel is not transparent (be it by
* enabling `compose.interop.blending` or setting the alpha channel of the panel's background to 0); all other
* renderers ignore it. When null, window clipping falls back to the `cornerSize`-based rounded corners (via JBR) if
* the platform supports it.
* @param content The composable content to be displayed inside the popup.
@@ -104,7 +105,7 @@ public fun Popup(
properties: PopupProperties = PopupProperties(),
onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null,
onKeyEvent: ((KeyEvent) -> Boolean)? = null,
windowShape: ((IntSize) -> java.awt.Shape)? = null,
windowShape: ((IntSize) -> Shape)? = null,
content: @Composable () -> Unit,
) {
Popup(
@@ -184,10 +185,10 @@ public fun Popup(
* consume the event.
* @param onKeyEvent Callback invoked for key events after they are dispatched to children. Return `true` to consume the
* event.
* @param windowShape An optional factory that produces the [java.awt.Shape] used to clip the native popup window. The
* lambda receives the window's measured size in AWT logical units and must return a shape in the same coordinate
* system. Only applied by JDialogRenderer when `useCustomPopupRenderer = true` and the panel is not transparent (be
* it by enabling `compose.interop.blending` or setting the alpha channel of the panel's background to 0); all other
* @param windowShape An optional factory that produces the [Shape] used to clip the native popup window. The lambda
* receives the window's measured size in AWT logical units and must return a shape in the same coordinate system.
* Only applied by `JDialogRenderer` when `useCustomPopupRenderer = true` and the panel is not transparent (be it by
* enabling `compose.interop.blending` or setting the alpha channel of the panel's background to 0); all other
* renderers ignore it. When null, window clipping falls back to the `cornerSize`-based rounded corners (via JBR) if
* the platform supports it.
* @param content The composable content to be displayed inside the popup.
@@ -200,7 +201,7 @@ public fun Popup(
properties: PopupProperties = PopupProperties(),
onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null,
onKeyEvent: ((KeyEvent) -> Boolean)? = null,
windowShape: ((IntSize) -> java.awt.Shape)? = null,
windowShape: ((IntSize) -> Shape)? = null,
content: @Composable () -> Unit,
) {
DisableSelection {
@@ -236,6 +237,11 @@ public fun Popup(
* [JewelFlags.useCustomPopupRenderer] flag to use it.
*/
public interface PopupRenderer {
/**
* Compatibility overload without native window-shape support. Implement the shaped overload instead.
*
* Parameters match the shaped overload.
*/
@Deprecated(message = "Please use the overload with windowShape.")
@Composable
public fun Popup(
@@ -248,6 +254,19 @@ public interface PopupRenderer {
content: @Composable () -> Unit,
)
/**
* Renders a popup, optionally clipping its native window to [windowShape]. Only native-window renderers apply the
* shape; the default fallback delegates to the deprecated overload and drops it.
*
* @param popupPositionProvider Determines the popup position.
* @param properties Popup focus and dismissal behavior.
* @param onDismissRequest Callback invoked when dismissal is requested.
* @param onPreviewKeyEvent Preview key handler; return `true` to consume an event.
* @param onKeyEvent Key handler; return `true` to consume an event.
* @param cornerSize Popup corner size.
* @param windowShape Native shape factory in AWT logical units, or `null` for the default shape.
* @param content Popup content.
*/
@Composable
public fun Popup(
popupPositionProvider: PopupPositionProvider,
@@ -256,9 +275,10 @@ public interface PopupRenderer {
onPreviewKeyEvent: ((KeyEvent) -> Boolean)?,
onKeyEvent: ((KeyEvent) -> Boolean)?,
cornerSize: CornerSize,
windowShape: ((IntSize) -> java.awt.Shape)? = null,
windowShape: ((IntSize) -> Shape)? = null,
content: @Composable () -> Unit,
) {
@Suppress("DEPRECATION")
Popup(
popupPositionProvider = popupPositionProvider,
properties = properties,
@@ -270,6 +290,7 @@ public interface PopupRenderer {
)
}
/** Companion object for [PopupRenderer]. Currently empty; kept as an extension point for extension functions. */
public companion object
}
@@ -283,7 +304,14 @@ public val LocalPopupRenderer: ProvidableCompositionLocal<PopupRenderer> = stati
}
private object DefaultPopupRenderer : PopupRenderer {
@Suppress("OVERRIDE_DEPRECATION")
@Deprecated(
"Please use the overload with windowShape.",
ReplaceWith(
"Popup(popupPositionProvider, properties, onDismissRequest, onPreviewKeyEvent, onKeyEvent, cornerSize, " +
"windowShape = null, content)",
"org.jetbrains.jewel.ui.component.DefaultPopupRenderer.Popup",
),
)
@Composable
override fun Popup(
popupPositionProvider: PopupPositionProvider,
@@ -294,6 +322,31 @@ private object DefaultPopupRenderer : PopupRenderer {
cornerSize: CornerSize,
content: @Composable () -> Unit,
) {
Popup(
popupPositionProvider = popupPositionProvider,
properties = properties,
onDismissRequest = onDismissRequest,
onPreviewKeyEvent = onPreviewKeyEvent,
onKeyEvent = onKeyEvent,
cornerSize = cornerSize,
windowShape = null,
content = content,
)
}
@Composable
override fun Popup(
popupPositionProvider: PopupPositionProvider,
properties: PopupProperties,
onDismissRequest: (() -> Unit)?,
onPreviewKeyEvent: ((KeyEvent) -> Boolean)?,
onKeyEvent: ((KeyEvent) -> Boolean)?,
cornerSize: CornerSize,
windowShape: ((IntSize) -> Shape)?,
content: @Composable () -> Unit,
) {
// Compose popups are rendered in the owner window and do not support native window shapes, so
// windowShape is ignored.
ComposePopup(
popupPositionProvider = popupPositionProvider,
onDismissRequest = onDismissRequest,
@@ -112,7 +112,8 @@ public fun SpeedSearchArea(
* @param textStyle The text style for the search input text.
* @param searchMatchStyle The styling for highlighting matched text in search results.
* @param interactionSource The interaction source for tracking focus state. If null, a new one will be created.
* @param dismissOnLoseFocus Whether to automatically hide the search input when it loses focus. Defaults to true.
* @param dismissOnLoseFocus Whether focus loss or renderer-level outside dismissal hides the search input. Escape
* behavior is defined by the content's [SpeedSearchScope.processKeyEvent] implementation. Defaults to true.
* @param content The content to be displayed within the speed search area. Use [SpeedSearchScope] to access search
* state and process key events.
*/
@@ -157,7 +158,8 @@ public fun SpeedSearchArea(
* @param textStyle The text style for the search input text.
* @param searchMatchStyle The styling for highlighting matched text in search results.
* @param interactionSource The interaction source for tracking focus state. If null, a new one will be created.
* @param dismissOnLoseFocus Whether to automatically hide the search input when it loses focus. Defaults to true.
* @param dismissOnLoseFocus Whether focus loss or renderer-level outside dismissal hides the search input. Escape
* behavior is defined by the content's [SpeedSearchScope.processKeyEvent] implementation. Defaults to true.
* @param content The content to be displayed within the speed search area. Use [SpeedSearchScope] to access search
* state and process key events.
*/
@@ -190,9 +192,11 @@ public fun SpeedSearchArea(
if (state.isVisible) {
SpeedSearchInput(
speedSearchState = state,
state = state.textFieldState,
hasMatch = state.hasMatches,
position = state.position,
dismissOnLoseFocus = dismissOnLoseFocus,
styling = styling,
textStyle = textStyle,
textFieldStyle = textFieldStyle,
@@ -390,9 +394,11 @@ public fun ProvideSearchMatchState(
@Composable
private fun SpeedSearchInput(
speedSearchState: SpeedSearchState,
state: TextFieldState,
hasMatch: Boolean,
position: Alignment.Vertical,
dismissOnLoseFocus: Boolean,
styling: SpeedSearchStyle,
textStyle: TextStyle,
textFieldStyle: TextFieldStyle,
@@ -407,7 +413,10 @@ private fun SpeedSearchInput(
}
}
Popup(popupPositionProvider = rememberComponentRectPositionProvider(anchor, alignment)) {
Popup(
popupPositionProvider = rememberComponentRectPositionProvider(anchor, alignment),
onDismissRequest = if (dismissOnLoseFocus) ({ speedSearchState.hideSearch() }) else null,
) {
val focusRequester = remember { FocusRequester() }
BasicTextField(
@@ -299,7 +299,9 @@ private fun <T : Any> SpeedSearchScope.SpeedSearchableComboBoxImpl(
modifier.onPreviewKeyEvent { event ->
if (!popupVisible) return@onPreviewKeyEvent false
if (!processKeyEvent(event) && speedSearchState.isVisibleAndNotEmpty) {
if (processKeyEvent(event)) return@onPreviewKeyEvent true
if (speedSearchState.isVisibleAndNotEmpty) {
val actionHandled =
speedSearchKeyActions
.handleOnKeyEvent(