diff --git a/platform/jewel/ide-laf-bridge/api-dump.txt b/platform/jewel/ide-laf-bridge/api-dump.txt index 6c55ae3548d6..868904b7de13 100644 --- a/platform/jewel/ide-laf-bridge/api-dump.txt +++ b/platform/jewel/ide-laf-bridge/api-dump.txt @@ -28,6 +28,7 @@ f:org.jetbrains.jewel.bridge.BridgeUtilsKt - sf:createVerticalBrush-8A-3gB4(java.util.List,F,F,I):androidx.compose.ui.graphics.Brush - bs:createVerticalBrush-8A-3gB4$default(java.util.List,F,F,I,I,java.lang.Object):androidx.compose.ui.graphics.Brush - sf:getDp(com.intellij.util.ui.JBValue):F +- sf:getUnscaledDp(I):F - sf:retrieveArcAsCornerSize(java.lang.String):androidx.compose.foundation.shape.CornerSize - sf:retrieveArcAsCornerSizeOrDefault(java.lang.String,androidx.compose.foundation.shape.CornerSize):androidx.compose.foundation.shape.CornerSize - sf:retrieveArcAsCornerSizeWithFallbacks(java.lang.String[]):androidx.compose.foundation.shape.CornerSize @@ -49,6 +50,8 @@ f:org.jetbrains.jewel.bridge.BridgeUtilsKt - bs:retrieveTextStyle-WdJyH8Q$default(java.lang.String,J,J,Z,I,J,I,java.lang.Object):androidx.compose.ui.text.TextStyle - sf:retrieveTextStyle-tD9LlGs(java.lang.String,java.lang.String,J,Z,I,J):androidx.compose.ui.text.TextStyle - bs:retrieveTextStyle-tD9LlGs$default(java.lang.String,java.lang.String,J,Z,I,J,I,java.lang.Object):androidx.compose.ui.text.TextStyle +- sf:retrieveUnscaledIntAsDpOrUnspecified(java.lang.String):F +- sf:retrieveUnscaledIntAsNonNegativeDpOrUnspecified(java.lang.String):F - sf:toAwtColor-8_81llA(J):java.awt.Color - sf:toAwtColorOrNull-8_81llA(J):java.awt.Color - sf:toComposeColor(java.awt.Color):J diff --git a/platform/jewel/ide-laf-bridge/metalava/ide-laf-bridge-api-0.39.0.txt b/platform/jewel/ide-laf-bridge/metalava/ide-laf-bridge-api-0.39.0.txt index ce8777313c20..7631bf288eb6 100644 --- a/platform/jewel/ide-laf-bridge/metalava/ide-laf-bridge-api-0.39.0.txt +++ b/platform/jewel/ide-laf-bridge/metalava/ide-laf-bridge-api-0.39.0.txt @@ -63,6 +63,8 @@ package org.jetbrains.jewel.bridge { method public static androidx.compose.ui.text.PlatformTextStyle retrievePlatformTextStyle(); method public static androidx.compose.ui.text.TextStyle retrieveTextStyle(String fontKey, optional String? colorKey, optional long lineHeight, optional boolean bold, optional int fontStyle, optional long size); method public static androidx.compose.ui.text.TextStyle retrieveTextStyle(String key, optional long color, optional long lineHeight, optional boolean bold, optional int fontStyle, optional long size); + method public static float retrieveUnscaledIntAsDpOrUnspecified(String key); + method public static float retrieveUnscaledIntAsNonNegativeDpOrUnspecified(String key); method public static java.awt.Color toAwtColor(long); method public static java.awt.Color? toAwtColorOrNull(long); method public static long toComposeColor(java.awt.Color); @@ -74,6 +76,7 @@ package org.jetbrains.jewel.bridge { method public static androidx.compose.foundation.layout.PaddingValues toPaddingValues(com.intellij.util.ui.JBInsets); method public static androidx.compose.foundation.layout.PaddingValues toPaddingValues(java.awt.Insets); property public static androidx.compose.ui.unit.Dp com.intellij.util.ui.JBValue.dp; + property public static androidx.compose.ui.unit.Dp int.unscaledDp; } public abstract sealed class JewelBridgeException extends java.lang.RuntimeException { diff --git a/platform/jewel/ide-laf-bridge/metalava/ide-laf-bridge-api-stable-0.39.0.txt b/platform/jewel/ide-laf-bridge/metalava/ide-laf-bridge-api-stable-0.39.0.txt index 7a92f2170c3d..d07a7de4e3b0 100644 --- a/platform/jewel/ide-laf-bridge/metalava/ide-laf-bridge-api-stable-0.39.0.txt +++ b/platform/jewel/ide-laf-bridge/metalava/ide-laf-bridge-api-stable-0.39.0.txt @@ -61,6 +61,8 @@ package org.jetbrains.jewel.bridge { method public static androidx.compose.ui.text.PlatformTextStyle retrievePlatformTextStyle(); method public static androidx.compose.ui.text.TextStyle retrieveTextStyle(String fontKey, optional String? colorKey, optional long lineHeight, optional boolean bold, optional int fontStyle, optional long size); method public static androidx.compose.ui.text.TextStyle retrieveTextStyle(String key, optional long color, optional long lineHeight, optional boolean bold, optional int fontStyle, optional long size); + method public static float retrieveUnscaledIntAsDpOrUnspecified(String key); + method public static float retrieveUnscaledIntAsNonNegativeDpOrUnspecified(String key); method public static java.awt.Color toAwtColor(long); method public static java.awt.Color? toAwtColorOrNull(long); method public static long toComposeColor(java.awt.Color); @@ -70,6 +72,7 @@ package org.jetbrains.jewel.bridge { method public static androidx.compose.foundation.layout.PaddingValues toPaddingValues(com.intellij.util.ui.JBInsets); method public static androidx.compose.foundation.layout.PaddingValues toPaddingValues(java.awt.Insets); property public static androidx.compose.ui.unit.Dp com.intellij.util.ui.JBValue.dp; + property public static androidx.compose.ui.unit.Dp int.unscaledDp; } public abstract sealed class JewelBridgeException extends java.lang.RuntimeException { diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/BridgeUtils.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/BridgeUtils.kt index a5833892685f..9ade067cea95 100644 --- a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/BridgeUtils.kt +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/BridgeUtils.kt @@ -42,6 +42,7 @@ import com.intellij.ui.scale.JBUIScale.scale import com.intellij.util.ui.JBDimension import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBInsets +import com.intellij.util.ui.JBUI import com.intellij.util.ui.JBValue import java.awt.Dimension import java.awt.Insets @@ -520,3 +521,58 @@ public fun retrieveEditorColorScheme(): EditorColorsScheme { val manager = EditorColorsManager.getInstance() as EditorColorsManagerImpl return manager.schemeManager.activeScheme ?: DefaultColorSchemesManager.getInstance().firstScheme } + +/** + * Retrieves a pre-scaled integer value from the current LaF as a non-negative [Dp], or returns [Dp.Unspecified] if the + * key is not found. + * + * Any negative values read from the LaF will be coerced to `0.dp`. + * + * Use this instead of [retrieveIntAsNonNegativeDpOrUnspecified] for keys whose values are explicitly rescaled by + * `patchHiDPI` in `LafManagerImpl`. See [unscaledDp] for the full list of affected keys and an explanation of why + * unscaling is needed. + * + * @param key The key to look up the integer with. + * @return The unscaled integer value from the LaF as a [Dp], or [Dp.Unspecified] if the key is not found. + * @see unscaledDp + */ +public fun retrieveUnscaledIntAsNonNegativeDpOrUnspecified(key: String): Dp = + retrieveUnscaledIntAsDpOrUnspecified(key).takeIf { it.isSpecified }?.safeValue() ?: Dp.Unspecified + +/** + * Retrieves a pre-scaled integer value from the current LaF as a [Dp], or returns [Dp.Unspecified] if the key is not + * found. + * + * Use this instead of [retrieveIntAsDpOrUnspecified] for keys whose values are explicitly rescaled by `patchHiDPI` in + * `LafManagerImpl`. See [unscaledDp] for the full list of affected keys and an explanation of why unscaling is needed. + * + * @param key The key to look up the integer with. + * @return The unscaled integer value from the LaF as a [Dp], or [Dp.Unspecified] if the key is not found. + * @see unscaledDp + */ +public fun retrieveUnscaledIntAsDpOrUnspecified(key: String): Dp { + val rawValue = UIManager.get(key) + if (rawValue is Int) return rawValue.unscaledDp + return Dp.Unspecified +} + +/** + * Converts a pre-scaled JBUI value into a Compose [Dp]. + * + * Certain integer values in UIDefaults are explicitly rescaled by `patchHiDPI` in `LafManagerImpl` after the theme JSON + * is applied. This means they already reflect the current JBUI scale factor. For example, `List.rowHeight` is stored as + * `24` in the theme JSON, but `patchHiDPI` overwrites it with `scale(24)`. So in Presentation Mode (scale=1.75), + * [com.intellij.util.ui.JBUI.CurrentTheme.List.rowHeight] returns `42`, not `24`. + * + * Passing such a pre-scaled value directly to `.dp` causes double-scaling: + * 1. `patchHiDPI` scales the stored value (e.g. 24 → 42 in Presentation Mode) + * 2. Compose scales it again via `LocalDensity` (e.g. `42.dp` → way too big) + * + * The keys currently explicitly rescaled by `patchHiDPI` are: `List.rowHeight`, `Table.rowHeight`, `Tree.rowHeight`, + * VCS log row height, `Tree.leftChildIndent`, `Tree.rightChildIndent`, and `SettingsTree.rowHeight`. + * + * For all other keys — those NOT rescaled by `patchHiDPI` — the value in UIDefaults is the raw logical integer from the + * theme JSON, and `.dp` is correct. Do NOT use this extension for those (e.g. via [retrieveIntAsDp] or similar). + */ +public val Int.unscaledDp: Dp + get() = JBUI.unscale(this).dp diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeLazyTree.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeLazyTree.kt index ca569fd4f905..dbbd9791eadd 100644 --- a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeLazyTree.kt +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeLazyTree.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.unit.takeOrElse import com.intellij.util.ui.JBUI import org.jetbrains.jewel.bridge.dp import org.jetbrains.jewel.bridge.retrieveColorOrUnspecified -import org.jetbrains.jewel.bridge.retrieveIntAsNonNegativeDpOrUnspecified +import org.jetbrains.jewel.bridge.retrieveUnscaledIntAsNonNegativeDpOrUnspecified import org.jetbrains.jewel.ui.component.styling.LazyTreeIcons import org.jetbrains.jewel.ui.component.styling.LazyTreeMetrics import org.jetbrains.jewel.ui.component.styling.LazyTreeStyle @@ -34,8 +34,8 @@ internal fun readLazyTreeStyle(): LazyTreeStyle { backgroundSelectedActive = selectedElementBackground, ) - val leftIndent = retrieveIntAsNonNegativeDpOrUnspecified("Tree.leftChildIndent").takeOrElse { 7.dp } - val rightIndent = retrieveIntAsNonNegativeDpOrUnspecified("Tree.rightChildIndent").takeOrElse { 11.dp } + val leftIndent = retrieveUnscaledIntAsNonNegativeDpOrUnspecified("Tree.leftChildIndent").takeOrElse { 7.dp } + val rightIndent = retrieveUnscaledIntAsNonNegativeDpOrUnspecified("Tree.rightChildIndent").takeOrElse { 11.dp } return LazyTreeStyle( colors = itemColors, @@ -49,7 +49,8 @@ internal fun readLazyTreeStyle(): LazyTreeStyle { selectionBackgroundCornerSize = CornerSize(JBUI.CurrentTheme.Tree.ARC.dp / 2), iconTextGap = 2.dp, ), - elementMinHeight = retrieveIntAsNonNegativeDpOrUnspecified("Tree.rowHeight").takeOrElse { 24.dp }, + elementMinHeight = + retrieveUnscaledIntAsNonNegativeDpOrUnspecified("Tree.rowHeight").takeOrElse { 24.dp }, chevronContentGap = 2.dp, // See com.intellij.ui.tree.ui.ClassicPainter.GAP ), icons = diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeMenu.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeMenu.kt index 414b51c5ec3e..f53c467ea540 100644 --- a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeMenu.kt +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/theme/IntUiBridgeMenu.kt @@ -16,6 +16,7 @@ import org.jetbrains.jewel.bridge.retrieveColorOrUnspecified import org.jetbrains.jewel.bridge.retrieveIntAsNonNegativeDpOrUnspecified import org.jetbrains.jewel.bridge.safeValue import org.jetbrains.jewel.bridge.toPaddingValues +import org.jetbrains.jewel.bridge.unscaledDp import org.jetbrains.jewel.ui.component.styling.MenuColors import org.jetbrains.jewel.ui.component.styling.MenuIcons import org.jetbrains.jewel.ui.component.styling.MenuItemColors @@ -107,7 +108,7 @@ internal fun readMenuStyle(): MenuStyle { iconSize = 16.dp, minHeight = if (isNewUiTheme()) { - JBUI.CurrentTheme.List.rowHeight().dp.safeValue() + JBUI.CurrentTheme.List.rowHeight().unscaledDp.safeValue() } else { Dp.Unspecified }, diff --git a/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/MenuContentTest.kt b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/MenuContentTest.kt index 8cb7da408e3b..5b14ac1f2fc0 100644 --- a/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/MenuContentTest.kt +++ b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/MenuContentTest.kt @@ -4,7 +4,7 @@ package org.jetbrains.jewel.ui.component import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.input.InputMode -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import javax.swing.KeyStroke import org.jetbrains.jewel.intui.standalone.menuShortcut.StandaloneShortcutProvider import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme diff --git a/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/MenuSubmenuItemTest.kt b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/MenuSubmenuItemTest.kt new file mode 100644 index 000000000000..5efb5988144e --- /dev/null +++ b/platform/jewel/ui-tests/src/test/kotlin/org/jetbrains/jewel/ui/component/MenuSubmenuItemTest.kt @@ -0,0 +1,120 @@ +package org.jetbrains.jewel.ui.component + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme +import org.jetbrains.jewel.ui.component.interactions.performKeyPress +import org.junit.Rule +import org.junit.Test + +class MenuSubmenuItemTest { + @get:Rule val rule = createComposeRule() + + @Test + fun `clicking submenu item opens submenu`() { + rule.setContent { + IntUiTheme { + CompositionLocalProvider(LocalMenuController provides FakeMenuController()) { + MenuSubmenuItem( + showIcon = false, + selected = false, + submenu = { selectableItem(false, onClick = {}) { Text("Submenu") } }, + content = { Text("Parent") }, + ) + } + } + } + + // submenu not yet visible + rule.onNode(hasText("Submenu").and(hasAnyAncestor(isPopup()))).assertDoesNotExist() + + rule.onNodeWithText("Parent").performClick() + rule.waitForIdle() + + rule.onNode(hasText("Submenu").and(hasAnyAncestor(isPopup()))).assertIsDisplayed() + } + + @Test + fun `disabling while submenu is open closes the submenu`() { + val enabled = mutableStateOf(true) + + rule.setContent { + IntUiTheme { + CompositionLocalProvider(LocalMenuController provides FakeMenuController()) { + MenuSubmenuItem( + showIcon = false, + selected = false, + enabled = enabled.value, + submenu = { selectableItem(false, onClick = {}) { Text("Submenu") } }, + content = { Text("Parent") }, + ) + } + } + } + + rule.onNodeWithText("Parent").performClick() + rule.waitForIdle() + rule.onNode(hasText("Submenu").and(hasAnyAncestor(isPopup()))).assertIsDisplayed() + + enabled.value = false + rule.waitForIdle() + + rule.onNode(hasText("Submenu").and(hasAnyAncestor(isPopup()))).assertDoesNotExist() + } + + @Test + fun `disabled items cannot be opened`() { + rule.setContent { + IntUiTheme { + CompositionLocalProvider(LocalMenuController provides FakeMenuController()) { + MenuSubmenuItem( + showIcon = false, + selected = false, + enabled = false, + submenu = { selectableItem(selected = false, onClick = {}) { Text("Submenu") } }, + content = { Text("Parent") }, + ) + } + } + } + + rule.onNodeWithText("Parent").performClick() + rule.waitForIdle() + + rule.onNode(hasText("Submenu").and(hasAnyAncestor(isPopup()))).assertDoesNotExist() + } + + @Test + fun `right arrow opens submenu`() { + rule.setContent { + IntUiTheme { + CompositionLocalProvider(LocalMenuController provides FakeMenuController()) { + MenuSubmenuItem( + showIcon = false, + selected = false, + submenu = { selectableItem(selected = false, onClick = {}) { Text("Submenu") } }, + content = { Text("Parent") }, + ) + } + } + } + + rule.onNodeWithText("Parent").performSemanticsAction(SemanticsActions.RequestFocus) + rule.waitForIdle() + + rule.onNodeWithText("Parent").performKeyPress(Key.DirectionRight) + rule.waitForIdle() + + rule.onNode(hasText("Submenu").and(hasAnyAncestor(isPopup()))).assertIsDisplayed() + } +} diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Menu.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Menu.kt index f508cd7c32be..4e452bf06891 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Menu.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Menu.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -821,36 +822,18 @@ internal fun MenuItemBase( style: MenuStyle = JewelTheme.menuStyle, @Suppress("DEPRECATION") content: @Composable (itemState: MenuItemState) -> Unit, ) { - var itemState by - remember(interactionSource) { - @Suppress("DEPRECATION") mutableStateOf(MenuItemState.of(selected = selected, enabled = enabled)) - } - - remember(enabled, selected) { itemState = itemState.copy(selected = selected, enabled = enabled) } + val itemState by rememberMenuItemState(selected, enabled, interactionSource) val focusRequester = remember { FocusRequester() } - - LaunchedEffect(interactionSource) { - interactionSource.interactions.collect { interaction -> - when (interaction) { - is PressInteraction.Press -> itemState = itemState.copy(pressed = true) - is PressInteraction.Cancel, - is PressInteraction.Release -> itemState = itemState.copy(pressed = false) - is HoverInteraction.Enter -> { - itemState = itemState.copy(hovered = true) - focusRequester.requestFocus() - } - - is HoverInteraction.Exit -> itemState = itemState.copy(hovered = false) - is FocusInteraction.Focus -> itemState = itemState.copy(focused = true) - is FocusInteraction.Unfocus -> itemState = itemState.copy(focused = false) - } - } - } - val menuController = LocalMenuController.current val localInputModeManager = LocalInputModeManager.current + LaunchedEffect(itemState.isHovered) { + if (itemState.isHovered) { + focusRequester.requestFocus() + } + } + Box( modifier = modifier @@ -872,52 +855,25 @@ internal fun MenuItemBase( onDispose {} } - val itemColors = style.colors.itemColors - val itemMetrics = style.metrics.itemMetrics - - @Suppress("DEPRECATION") // Not really deprecated, will be made internal - val updatedTextStyle = LocalTextStyle.current.copy(color = itemColors.contentFor(itemState).value) - - @Suppress("DEPRECATION") // Not really deprecated, will be made internal - CompositionLocalProvider( - LocalContentColor provides itemColors.contentFor(itemState).value, - LocalTextStyle provides updatedTextStyle, - ) { - val backgroundColor by itemColors.backgroundFor(itemState) - - Row( - modifier = - Modifier.fillMaxWidth() - .defaultMinSize(minHeight = itemMetrics.minHeight) - .drawItemBackground(itemMetrics, backgroundColor) - .padding(itemMetrics.contentPadding), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (canShowIcon) { - val iconModifier = Modifier.size(style.metrics.itemMetrics.iconSize) - if (iconKey != null) { - Icon( - key = iconKey, - contentDescription = null, - modifier = iconModifier.thenIf(!enabled) { disabledAppearance() }, - ) - } else { - Box(modifier = iconModifier) - } - } - - Box(modifier = Modifier.weight(1f, true)) { content(itemState) } - + MenuItemLayout( + itemState = itemState, + style = style, + iconKey = iconKey, + canShowIcon = canShowIcon, + enabled = enabled, + trailingContent = if (canShowKeybinding) { - Text( - modifier = Modifier.padding(style.metrics.itemMetrics.keybindingsPadding), - text = keybindingHint, - color = itemColors.keybindingTintFor(itemState).value, - ) - } - } - } + { + @Suppress("DEPRECATION") // Not really deprecated, MenuItemColors will be made internal + Text( + modifier = Modifier.padding(style.metrics.itemMetrics.keybindingsPadding), + text = keybindingHint, + color = style.colors.itemColors.keybindingTintFor(itemState).value, + ) + } + } else null, + content = { content(itemState) }, + ) } } @@ -939,8 +895,11 @@ public fun MenuSubmenuItem( } } +@VisibleForTesting +@ApiStatus.Internal +@InternalJewelApi @Composable -internal fun MenuSubmenuItem( +public fun MenuSubmenuItem( showIcon: Boolean, selected: Boolean, submenu: MenuScope.() -> Unit, @@ -951,43 +910,17 @@ internal fun MenuSubmenuItem( style: MenuStyle = JewelTheme.menuStyle, @Suppress("DEPRECATION") content: @Composable (itemState: MenuItemState) -> Unit, ) { - var itemState by - remember(interactionSource) { - @Suppress("DEPRECATION") mutableStateOf(MenuItemState.of(selected = selected, enabled = enabled)) - } - - remember(enabled) { itemState = itemState.copy(selected = false, enabled = enabled) } - + var itemState by rememberMenuItemState(selected, enabled, interactionSource) + // When the item becomes disabled, close any open submenu + remember(enabled) { if (!enabled) itemState = itemState.copy(selected = false) } val focusRequester = remember { FocusRequester() } - LaunchedEffect(interactionSource) { - interactionSource.interactions.collect { interaction -> - when (interaction) { - is PressInteraction.Press -> itemState = itemState.copy(pressed = true) - is PressInteraction.Cancel, - is PressInteraction.Release -> itemState = itemState.copy(pressed = false) - - is HoverInteraction.Enter -> itemState = itemState.copy(hovered = true) - is HoverInteraction.Exit -> itemState = itemState.copy(hovered = false) - is FocusInteraction.Focus -> itemState = itemState.copy(focused = true) - is FocusInteraction.Unfocus -> itemState = itemState.copy(focused = false) - } - } - } - - remember(selected) { itemState = itemState.copy(selected = selected) } LaunchedEffect(itemState.isSelected) { if (itemState.isSelected) focusRequester.requestFocus() } - val itemColors = style.colors.itemColors - val menuMetrics = style.metrics - - @Suppress("DEPRECATION") // Not really deprecated, will be made internal - val backgroundColor by itemColors.backgroundFor(itemState) Box( modifier = modifier .fillMaxWidth() - .drawItemBackground(menuMetrics.itemMetrics, backgroundColor) .focusRequester(focusRequester) .clickable( onClick = { itemState = itemState.copy(selected = !itemState.isSelected) }, @@ -1004,32 +937,23 @@ internal fun MenuSubmenuItem( } } ) { - @Suppress("DEPRECATION") // Not really deprecated, will be made internal - CompositionLocalProvider(LocalContentColor provides itemColors.contentFor(itemState).value) { - Row( - Modifier.fillMaxWidth().padding(menuMetrics.itemMetrics.contentPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (showIcon) { - if (iconKey != null) { - Icon(key = iconKey, contentDescription = null) - } else { - Box(Modifier.size(style.metrics.itemMetrics.iconSize)) - } - } - - Box(Modifier.weight(1f)) { content(itemState) } - + MenuItemLayout( + itemState = itemState, + style = style, + iconKey = iconKey, + canShowIcon = showIcon, + trailingContent = { + @Suppress("DEPRECATION") // Not really deprecated, MenuItemColors will be made internal Icon( key = style.icons.submenuChevron, - tint = itemColors.iconTintFor(itemState).value, contentDescription = null, modifier = Modifier.size(style.metrics.itemMetrics.iconSize), + tint = style.colors.itemColors.iconTintFor(itemState).value, hint = Stateful(itemState), ) - } - } + }, + content = { content(itemState) }, + ) if (itemState.isSelected) { Submenu( @@ -1048,6 +972,60 @@ internal fun MenuSubmenuItem( } } +@Suppress("DEPRECATION") // Not really deprecated, MenuItemColors be made internal +@Composable +internal fun MenuItemLayout( + itemState: MenuItemState, + modifier: Modifier = Modifier, + style: MenuStyle = JewelTheme.menuStyle, + iconKey: IconKey? = null, + canShowIcon: Boolean = true, + enabled: Boolean = true, + trailingContent: (@Composable () -> Unit)? = null, + content: @Composable () -> Unit, +) { + val itemColors = style.colors.itemColors + val itemMetrics = style.metrics.itemMetrics + + val contentColor = itemColors.contentFor(itemState).value + val backgroundColor by itemColors.backgroundFor(itemState) + + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalTextStyle provides LocalTextStyle.current.copy(color = contentColor), + ) { + Row( + modifier = + modifier + .fillMaxWidth() + .defaultMinSize(minHeight = itemMetrics.minHeight) + .drawItemBackground(itemMetrics, backgroundColor) + .padding(itemMetrics.contentPadding), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (canShowIcon) { + val iconModifier = Modifier.size(itemMetrics.iconSize) + if (iconKey != null) { + Icon( + key = iconKey, + contentDescription = null, + modifier = iconModifier.thenIf(!enabled) { disabledAppearance() }, + ) + } else { + Box(modifier = iconModifier) + } + } + + Box(modifier = Modifier.weight(1f)) { content() } + + if (trailingContent != null) { + trailingContent() + } + } + } +} + private fun Modifier.drawItemBackground(itemMetrics: MenuItemMetrics, backgroundColor: Color) = drawBehind { val cornerSizePx = itemMetrics.selectionCornerSize.toPx(size, density = this) val cornerRadius = CornerRadius(cornerSizePx, cornerSizePx) @@ -1201,3 +1179,32 @@ public value class MenuItemState(public val state: ULong) : SelectableComponentS } } } + +@Suppress("DEPRECATION") // Not really deprecated, MenuItemState will be made internal +@Composable +internal fun rememberMenuItemState( + selected: Boolean, + enabled: Boolean, + interactionSource: MutableInteractionSource, +): MutableState { + val itemState = + remember(interactionSource) { mutableStateOf(MenuItemState.of(selected = selected, enabled = enabled)) } + + remember(enabled, selected) { itemState.value = itemState.value.copy(selected = selected, enabled = enabled) } + + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + when (interaction) { + is PressInteraction.Press -> itemState.value = itemState.value.copy(pressed = true) + is PressInteraction.Cancel, + is PressInteraction.Release -> itemState.value = itemState.value.copy(pressed = false) + is HoverInteraction.Enter -> itemState.value = itemState.value.copy(hovered = true) + is HoverInteraction.Exit -> itemState.value = itemState.value.copy(hovered = false) + is FocusInteraction.Focus -> itemState.value = itemState.value.copy(focused = true) + is FocusInteraction.Unfocus -> itemState.value = itemState.value.copy(focused = false) + } + } + } + + return itemState +} diff --git a/plugins/devkit/intellij.devkit.compose/resources/messages/DevkitComposeBundle.properties b/plugins/devkit/intellij.devkit.compose/resources/messages/DevkitComposeBundle.properties index b189cc481510..d7f249d456d6 100644 --- a/plugins/devkit/intellij.devkit.compose/resources/messages/DevkitComposeBundle.properties +++ b/plugins/devkit/intellij.devkit.compose/resources/messages/DevkitComposeBundle.properties @@ -76,6 +76,9 @@ jewel.swing.badge.green=Green jewel.swing.badge.green.secondary=Green secondary jewel.swing.badge.purple.secondary=Purple secondary jewel.swing.badge.gray.secondary=Gray secondary +jewel.section.menu.label=Menus: +jewel.section.menu.swing.button=Open IntelliJ Platform Menu +jewel.section.menu.swing.submenu=Submenu compose.sandbox=Compose Sandbox compose.sandbox.show.automatically.on.project.open=Show automatically on project open diff --git a/plugins/devkit/intellij.devkit.compose/src/demo/SwingComparisonTabPanel.kt b/plugins/devkit/intellij.devkit.compose/src/demo/SwingComparisonTabPanel.kt index dee71fb8b0ea..2a6262b8809b 100644 --- a/plugins/devkit/intellij.devkit.compose/src/demo/SwingComparisonTabPanel.kt +++ b/plugins/devkit/intellij.devkit.compose/src/demo/SwingComparisonTabPanel.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -19,6 +20,7 @@ import androidx.compose.runtime.mutableIntStateOf 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.graphics.Color import androidx.compose.ui.platform.LocalDensity @@ -30,6 +32,11 @@ import com.intellij.devkit.compose.DevkitComposeBundle import com.intellij.devkit.compose.icons.DevkitComposeIcons import com.intellij.icons.AllIcons import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.editor.colors.EditorFontType import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.popup.JBPopup @@ -63,23 +70,24 @@ import org.jetbrains.jewel.ui.component.DefaultSplitButton import org.jetbrains.jewel.ui.component.EditableListComboBox import org.jetbrains.jewel.ui.component.ExternalLink import org.jetbrains.jewel.ui.component.Icon -import org.jetbrains.jewel.ui.component.InformationDefaultBanner import org.jetbrains.jewel.ui.component.InlineInformationBanner import org.jetbrains.jewel.ui.component.ListComboBox import org.jetbrains.jewel.ui.component.OutlinedButton import org.jetbrains.jewel.ui.component.OutlinedSplitButton +import org.jetbrains.jewel.ui.component.PopupMenu import org.jetbrains.jewel.ui.component.Text import org.jetbrains.jewel.ui.component.TextArea import org.jetbrains.jewel.ui.component.TextField +import org.jetbrains.jewel.ui.component.separator import org.jetbrains.jewel.ui.disabledAppearance import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.jewel.ui.theme.badgeStyle import org.jetbrains.jewel.ui.theme.textAreaStyle import org.jetbrains.jewel.ui.typography -import java.awt.Dimension import java.awt.MouseInfo import javax.swing.BoxLayout import javax.swing.DefaultComboBoxModel +import javax.swing.JComponent import javax.swing.JLabel import javax.swing.JPanel import org.jetbrains.jewel.ui.component.Badge as JewelBadge @@ -108,6 +116,8 @@ internal class SwingComparisonTabPanel : BorderLayoutPanel() { separator() badgesRow() separator() + menusRow() + separator() } .apply { border = JBUI.Borders.empty(0, 10) @@ -713,6 +723,79 @@ internal class SwingComparisonTabPanel : BorderLayoutPanel() { }.layout(RowLayout.PARENT_GRID) } + private fun Panel.menusRow() { + row(DevkitComposeBundle.message("jewel.section.menu.label")) { + button(DevkitComposeBundle.message("jewel.section.menu.swing.button")) { + val actionGroup = createIntelliJMenuActionGroup() + val popupMenu = ActionManager.getInstance().createActionPopupMenu(ActionPlaces.POPUP, actionGroup) + val button = it.source as? JComponent + button?.let { comp -> + popupMenu.setTargetComponent(comp) + popupMenu.component.show(comp, 0, comp.height) + } + } + .align(AlignY.CENTER) + + compose { + var showJewelMenu by remember { mutableStateOf(false) } + + Box(modifier = Modifier.height(150.dp)) { + OutlinedButton(onClick = { showJewelMenu = true }, modifier = Modifier.align(Alignment.Center)) { + Text("Open Jewel Menu") + } + + if (showJewelMenu) { + PopupMenu( + onDismissRequest = { showJewelMenu = false; true }, + horizontalAlignment = Alignment.Start, + ) { + selectableItem(selected = false, onClick = {}) { Text("Menu Item 1") } + selectableItem(selected = false, onClick = {}) { Text("Menu Item 2") } + selectableItem(selected = false, onClick = {}) { Text("Menu Item 3") } + separator() + submenu(submenu = { + selectableItem(selected = false, onClick = {}) { Text("Submenu Item 1") } + selectableItem(selected = false, onClick = {}) { Text("Submenu Item 2") } + selectableItem(selected = false, onClick = {}) { Text("Submenu Item 3") } + }) { + Text("Submenu") + } + separator() + selectableItem(selected = false, onClick = {}) { Text("Menu Item 4") } + } + } + } + }.align(AlignY.CENTER) + } + .layout(RowLayout.PARENT_GRID) + } + + private fun createIntelliJMenuActionGroup(): DefaultActionGroup { + return DefaultActionGroup().apply { + add(createSimpleAction("Menu Item 1")) + add(createSimpleAction("Menu Item 2")) + add(createSimpleAction("Menu Item 3")) + addSeparator() + + val submenu = DefaultActionGroup(DevkitComposeBundle.message("jewel.section.menu.swing.submenu"), true) + submenu.add(createSimpleAction("Submenu Item 1")) + submenu.add(createSimpleAction("Submenu Item 2")) + submenu.add(createSimpleAction("Submenu Item 3")) + add(submenu) + + addSeparator() + add(createSimpleAction("Menu Item 4")) + } + } + + private fun createSimpleAction(text: String): AnAction { + return object : AnAction(text) { + override fun actionPerformed(e: AnActionEvent) { + // No-op for demo purposes + } + } + } + private fun PaddingValues.vertical(): Dp = calculateTopPadding() + calculateBottomPadding() private fun PaddingValues.horizontal(layoutDirection: LayoutDirection): Dp =