diff --git a/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/swingexample/Main.jvm.kt b/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/swingexample/Main.jvm.kt index 3d8032e967ac1..63a69ec63cf8e 100644 --- a/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/swingexample/Main.jvm.kt +++ b/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/swingexample/Main.jvm.kt @@ -265,15 +265,17 @@ fun CustomTextMenuProvider(content: @Composable () -> Unit) { private fun AnnotatedString.crop() = if (length <= 5) toString() else "${take(5)}..." +@OptIn(ExperimentalFoundationApi::class) private fun swingItem( label: String, color: java.awt.Color, key: Int, - onClick: () -> Unit + menuItemAction: TextContextMenu.Action ) = JMenuItem(label).apply { icon = circleIcon(color) accelerator = KeyStroke.getKeyStroke(key, if (hostOs.isMacOS) META_DOWN_MASK else CTRL_DOWN_MASK) - addActionListener { onClick() } + isEnabled = menuItemAction.enabled + addActionListener { menuItemAction.execute() } } private fun circleIcon(color: java.awt.Color) = object : Icon { diff --git a/compose/foundation/foundation/api/desktop/foundation.api b/compose/foundation/foundation/api/desktop/foundation.api index e9f40d5f44511..f3759bfe23266 100644 --- a/compose/foundation/foundation/api/desktop/foundation.api +++ b/compose/foundation/foundation/api/desktop/foundation.api @@ -84,6 +84,8 @@ public final class androidx/compose/foundation/ContextMenuData { public class androidx/compose/foundation/ContextMenuItem { public static final field $stable I public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V + public fun (Ljava/lang/String;ZLkotlin/jvm/functions/Function0;)V + public final fun contextMenuItemBaseClassEnabled ()Z public fun equals (Ljava/lang/Object;)Z public final fun getLabel ()Ljava/lang/String; public final fun getOnClick ()Lkotlin/jvm/functions/Function0; @@ -137,7 +139,8 @@ public final class androidx/compose/foundation/DarkThemeKt { public final class androidx/compose/foundation/DefaultContextMenuRepresentation : androidx/compose/foundation/ContextMenuRepresentation { public static final field $stable I - public synthetic fun (JJJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JJJJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JJJJLkotlin/jvm/internal/DefaultConstructorMarker;)V public fun Representation (Landroidx/compose/foundation/ContextMenuState;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V } diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.desktop.kt index b71fae28a6c71..2e1ae2c4b7701 100644 --- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.desktop.kt +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.desktop.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.text.contextmenu.data.TextContextMenuItemWith import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSession import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -65,42 +66,48 @@ val DarkDefaultContextMenuRepresentation = DefaultContextMenuRepresentation( class DefaultContextMenuRepresentation( private val backgroundColor: Color, private val textColor: Color, - private val itemHoverColor: Color + private val itemHoverColor: Color, + private val disabledTextColor: Color = textColor.copy(alpha = 0.38f), ) : ContextMenuRepresentation { @OptIn(ExperimentalComposeUiApi::class) @Composable override fun Representation(state: ContextMenuState, items: () -> List) { val status = state.status - if (status is ContextMenuState.Status.Open) { - val session = remember(state) { - object : TextContextMenuSession { - override fun close() { - state.status = ContextMenuState.Status.Closed - } + if (status !is ContextMenuState.Status.Open) return + + val session = remember(state) { + object : TextContextMenuSession { + override fun close() { + state.status = ContextMenuState.Status.Closed } } - val components by remember { - derivedStateOf { - items().map { - TextContextMenuItemWithComposableLeadingIcon( - key = it, - label = it.label, - enabled = true, - onClick = { - session.close() - it.onClick() - } - ) - } + } + val components by remember { + derivedStateOf { + items().map { + TextContextMenuItemWithComposableLeadingIcon( + key = it, + label = it.label, + enabled = it.enabled, + onClick = { + session.close() + it.onClick() + } + ) } } - val colors = remember(backgroundColor, textColor, itemHoverColor) { + } + + if (components.isEmpty()) { + SideEffect { session.close() } + } else { + val colors = remember(backgroundColor, textColor, itemHoverColor, disabledTextColor) { ContextMenuColors( backgroundColor = backgroundColor, textColor = textColor, - iconColor = Color.Unspecified, - disabledTextColor = Color.Unspecified, - disabledIconColor = Color.Unspecified, + iconColor = textColor, + disabledTextColor = disabledTextColor, + disabledIconColor = disabledTextColor, hoverColor = itemHoverColor, ) } diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/ContextMenuProvider.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/ContextMenuProvider.desktop.kt index eed6fd3b6c08b..db1b9d8098383 100644 --- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/ContextMenuProvider.desktop.kt +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/ContextMenuProvider.desktop.kt @@ -186,19 +186,27 @@ private suspend fun AwaitPointerEventScope.awaitEventFirstDown(): PointerEvent { * Individual element of context menu. * * @param label The text to be displayed as a context menu item. + * @param enabled Whether the item is enabled. * @param onClick The action to be executed after click on the item. */ open class ContextMenuItem( val label: String, + enabled: Boolean, val onClick: () -> Unit ) { + constructor(label: String, onClick: () -> Unit) : this(label, true, onClick) + + // Avoid breaking backwards compatibility with subclasses that already have an `enabled` field + private val _enabled = enabled + val enabled: Boolean + @JvmName("contextMenuItemBaseClassEnabled") get() = _enabled + override fun equals(other: Any?): Boolean { if (this === other) return true - if (other == null || this::class != other::class) return false - - other as ContextMenuItem + if (other !is ContextMenuItem) return false if (label != other.label) return false + if (enabled != other.enabled) return false if (onClick != other.onClick) return false return true @@ -206,12 +214,13 @@ open class ContextMenuItem( override fun hashCode(): Int { var result = label.hashCode() + result = 31 * result + enabled.hashCode() result = 31 * result + onClick.hashCode() return result } override fun toString(): String { - return "ContextMenuItem(label='$label')" + return "ContextMenuItem(label='$label', enabled=$enabled)" } } diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/ContextMenu.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/ContextMenu.desktop.kt index 9a807612bdbbf..bbf879fc9a71d 100644 --- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/ContextMenu.desktop.kt +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/ContextMenu.desktop.kt @@ -137,45 +137,41 @@ private val TextFieldSelectionManager.textManager: TextManager get() = object : val isPassword get() = visualTransformation is PasswordVisualTransformation - override val cut: (() -> Unit)? get() = - if (!value.selection.collapsed && editable && !isPassword) { - { + override val cut: TextContextMenu.Action get() = + TextContextMenu.Action( + enabled = !value.selection.collapsed && editable && !isPassword, + execute = { cut() focusRequester?.requestFocus() } - } else { - null - } + ) - override val copy: (() -> Unit)? get() = - if (!value.selection.collapsed && !isPassword) { - { + override val copy: TextContextMenu.Action get() = + TextContextMenu.Action( + enabled = !value.selection.collapsed && !isPassword, + execute = { copy(false) focusRequester?.requestFocus() } - } else { - null - } + ) - override val paste: (() -> Unit)? get() = - if (editable && clipboard?.nativeClipboardHasText() == true) { - { + override val paste: TextContextMenu.Action get() = + TextContextMenu.Action( + enabled = editable && clipboard?.nativeClipboardHasText() == true, + execute = { paste() focusRequester?.requestFocus() } - } else { - null - } + ) - override val selectAll: (() -> Unit)? get() = - if (value.selection.length != value.text.length) { - { + override val selectAll: TextContextMenu.Action get() = + TextContextMenu.Action( + enabled = value.selection.length != value.text.length, + execute = { selectAll() focusRequester?.requestFocus() } - } else { - null - } + ) override fun selectWordAtPositionIfNotAlreadySelected(offset: Offset) { this@textManager.selectWordAtPositionIfNotAlreadySelected(offset) @@ -198,20 +194,32 @@ private fun TextFieldSelectionState.textManager(coroutineScope: CoroutineScope): private fun pasteImpl() = launchUndispatched { paste() } - override val cut: (() -> Unit)? - get() = if (canShowCutMenuItem()) ::cutImpl else null + override val cut: TextContextMenu.Action get() = + TextContextMenu.Action( + enabled = canShowCutMenuItem(), + execute = ::cutImpl + ) - override val copy: (() -> Unit)? - get() = if (canShowCopyMenuItem()) ::copyImpl else null + override val copy: TextContextMenu.Action get() = + TextContextMenu.Action( + enabled = canShowCopyMenuItem(), + execute = ::copyImpl + ) - override val paste: (() -> Unit)? - get() { - launchUndispatched { updateClipboardEntry() } - return if (canShowPasteMenuItem()) ::pasteImpl else null - } + override val paste: TextContextMenu.Action get() = + TextContextMenu.Action( + enabled = run { + launchUndispatched { updateClipboardEntry() } + canShowPasteMenuItem() + }, + execute = ::pasteImpl + ) - override val selectAll: (() -> Unit)? - get() = if (canShowSelectAllMenuItem()) ::selectAll else null + override val selectAll: TextContextMenu.Action get() = + TextContextMenu.Action( + enabled = canShowSelectAllMenuItem(), + execute = ::selectAll + ) override fun selectWordAtPositionIfNotAlreadySelected(offset: Offset) { if (!textLayoutState.isPositionOnText(offset)) return @@ -236,7 +244,11 @@ private fun TextFieldSelectionState.textManager(coroutineScope: CoroutineScope): private val SelectionManager.textManager: TextManager get() = object : TextManager { override val selectedText get() = getSelectedText() ?: AnnotatedString("") override val cut = null - override val copy = { copy() } + override val copy get() = + TextContextMenu.Action( + enabled = isNonEmptySelection(), + execute = { copy()} + ) override val paste = null override val selectAll = null override fun selectWordAtPositionIfNotAlreadySelected(offset: Offset) { @@ -283,22 +295,22 @@ interface TextContextMenu { /** * Action for cutting the selected text to the clipboard. Null if there is no text to cut. */ - val cut: (() -> Unit)? + val cut: Action? /** * Action for copy the selected text to the clipboard. Null if there is no text to copy. */ - val copy: (() -> Unit)? + val copy: Action? /** * Action for pasting text from the clipboard. Null if there is no text in the clipboard. */ - val paste: (() -> Unit)? + val paste: Action? /** * Action for selecting the whole text. Null if the text is already selected. */ - val selectAll: (() -> Unit)? + val selectAll: Action? /** * Selects the word at the given [offset], unless the current selection already encompasses @@ -307,40 +319,56 @@ interface TextContextMenu { fun selectWordAtPositionIfNotAlreadySelected(offset: Offset) } + @ExperimentalFoundationApi + class Action(val enabled: Boolean, val execute: () -> Unit) + companion object { /** * [TextContextMenu] that is used by default in Compose. */ @ExperimentalFoundationApi - val Default = object : TextContextMenu { - @Composable - override fun Area(textManager: TextManager, state: ContextMenuState, content: @Composable () -> Unit) { - val localization = LocalLocalization.current - val items = { - listOfNotNull( - textManager.cut?.let { - ContextMenuItem(localization.cut, it) - }, - textManager.copy?.let { - ContextMenuItem(localization.copy, it) - }, - textManager.paste?.let { - ContextMenuItem(localization.paste, it) - }, - textManager.selectAll?.let { - ContextMenuItem(localization.selectAll, it) - }, - ) - } - - TextContextMenuArea( - textManager = textManager, - items = items, - state = state, - content = content - ) + val Default: TextContextMenu = BasicTextContextMenu(showDisabledItems = true) + + /** + * [TextContextMenu] that doesn't show any disabled items. + */ + @ExperimentalFoundationApi + val HideDisabledMenuItems: TextContextMenu = BasicTextContextMenu(showDisabledItems = false) + } +} + +/** + * Basic implementation of [TextContextMenu]. + */ +private class BasicTextContextMenu( + val showDisabledItems: Boolean +) : TextContextMenu { + @Composable + override fun Area( + textManager: TextManager, + state: ContextMenuState, + content: @Composable () -> Unit + ) { + val localization = LocalLocalization.current + val items = { + listOf( + localization.cut to textManager.cut, + localization.copy to textManager.copy, + localization.paste to textManager.paste, + localization.selectAll to textManager.selectAll, + ).mapNotNull { (localization, action) -> + if (action == null) return@mapNotNull null + if (!showDisabledItems && !action.enabled) return@mapNotNull null + ContextMenuItem(localization, action.enabled, action.execute) } } + + TextContextMenuArea( + textManager = textManager, + items = items, + state = state, + content = content + ) } } diff --git a/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/ContextMenuTest.kt b/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/ContextMenuTest.kt index cce5546931654..169203210d91a 100644 --- a/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/ContextMenuTest.kt +++ b/compose/foundation/foundation/src/desktopTest/kotlin/androidx/compose/foundation/ContextMenuTest.kt @@ -17,10 +17,20 @@ package androidx.compose.foundation import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.LocalTextContextMenu +import androidx.compose.foundation.text.TextContextMenu +import androidx.compose.foundation.text.TextContextMenuArea import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -33,10 +43,12 @@ import androidx.compose.ui.platform.LocalLocalization import androidx.compose.ui.platform.NativeClipboard import androidx.compose.ui.platform.PlatformLocalization import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.click import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -46,6 +58,7 @@ import androidx.compose.ui.test.rightClick import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp import androidx.navigationevent.DirectNavigationEventInput import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner import java.awt.datatransfer.StringSelection @@ -160,8 +173,8 @@ class ContextMenuTest { onNodeWithText(localization.selectAll).assertDoesNotExist() onNodeWithTag("textfield").performMouseInput { rightClick() } - onNodeWithText(localization.copy).assertMenuItemDoesNotExistOrIsDisabled() - onNodeWithText(localization.cut).assertMenuItemDoesNotExistOrIsDisabled() + onNodeWithText(localization.copy).assertIsNotEnabled() + onNodeWithText(localization.cut).assertIsNotEnabled() onNodeWithText(localization.paste).assertExists() onNodeWithText(localization.selectAll).assertExists() @@ -176,7 +189,7 @@ class ContextMenuTest { onNodeWithText(localization.copy).assertExists() onNodeWithText(localization.cut).assertExists() onNodeWithText(localization.paste).assertExists() - onNodeWithText(localization.selectAll).assertMenuItemDoesNotExistOrIsDisabled() + onNodeWithText(localization.selectAll).assertIsNotEnabled() navEventInput.backCompleted() onNodeWithText(localization.copy).assertDoesNotExist() @@ -185,15 +198,6 @@ class ContextMenuTest { onNodeWithText(localization.selectAll).assertDoesNotExist() } - private fun SemanticsNodeInteraction.assertMenuItemDoesNotExistOrIsDisabled() { - // New context menus disabled items; old context menus don't show disabled items. - if (ComposeFoundationFlags.isNewContextMenuEnabled) { - assertIsNotEnabled() - } else { - assertDoesNotExist() - } - } - @Test fun contextMenuClosesAfterMenuItemSelection_btf1() = contextMenuClosesAfterMenuItemSelection(useBtf2 = false) @@ -236,9 +240,93 @@ class ContextMenuTest { } } + // https://youtrack.jetbrains.com/issue/CMP-9329 + @Test + fun `contextMenuArea disables or removes copy action for SelectionContainer with empty selection`() = + runContextMenuTest { + val localization = object : PlatformLocalization { + override val copy = "copy" + override val cut = "cut" + override val paste = "paste" + override val selectAll = "selectAll" + } + + var textContextMenu by mutableStateOf(TextContextMenu.Default) + setContent { + CompositionLocalProvider( + LocalLocalization provides localization, + LocalTextContextMenu provides textContextMenu + ) { + SelectionContainer { + BasicText("Hello, row 1") + Box(Modifier.testTag("box").size(16.dp)) + BasicText("Hello, row 2") + } + } + } + + // Check disabled variant + onNodeWithTag("box").performMouseInput { rightClick() } + onNodeWithText(localization.copy).let { + it.assertExists() + it.assertIsNotEnabled() + } + + // Close the menu + onNode(ContextMenuMatcher).assertExists() + onNodeWithTag("box").performMouseInput { click(Offset.Zero) } + onNode(ContextMenuMatcher).assertDoesNotExist() // Verify it has been closed + + // Check hiding variant + textContextMenu = TextContextMenu.HideDisabledMenuItems + waitForIdle() + onNodeWithTag("box").performMouseInput { rightClick() } + onNodeWithText(localization.copy).assertDoesNotExist() + } + + // https://youtrack.jetbrains.com/issue/CMP-9342 + @Test + fun `empty text context menu is not shown`() = + runContextMenuTest { + val emptyTextContextMenu = object : TextContextMenu { + @Composable + override fun Area( + textManager: TextContextMenu.TextManager, + state: ContextMenuState, + content: @Composable (() -> Unit) + ) { + TextContextMenuArea( + textManager = textManager, + items = { emptyList() }, + state = state, + content = content + ) + } + } + + setContent { + CompositionLocalProvider( + LocalTextContextMenu provides emptyTextContextMenu + ) { + SelectionContainer { + BasicText("Hello, row 1") + Box(Modifier.testTag("box").size(16.dp)) + BasicText("Hello, row 2") + } + } + } + + onNodeWithTag("box").performMouseInput { rightClick() } + onNode(ContextMenuMatcher).assertDoesNotExist() + } + private fun runContextMenuTest(block: ComposeUiTest.() -> Unit) = runComposeUiTest { DesktopPlatform.withOverriddenCurrent(DesktopPlatform.Unknown) { block() } } -} \ No newline at end of file +} + +private val ContextMenuMatcher = SemanticsMatcher("Context menu") { + SemanticsProperties.IsPopup in it.config +} diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/PlatformLocalizationTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/PlatformLocalizationTest.kt index b929536774ef7..6be1abbe9ecf3 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/PlatformLocalizationTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/PlatformLocalizationTest.kt @@ -87,13 +87,18 @@ class PlatformLocalizationTest { get() = "My Select All" } + val emptyAction = TextContextMenu.Action( + enabled = true, + execute = { } + ) + val textManager = object: TextContextMenu.TextManager { override val selectedText: AnnotatedString get() = AnnotatedString("") - override val cut = { } - override val copy = { } - override val paste = { } - override val selectAll = { } + override val cut = emptyAction + override val copy = emptyAction + override val paste = emptyAction + override val selectAll = emptyAction override fun selectWordAtPositionIfNotAlreadySelected(offset: Offset) { } }