diff --git a/compose/foundation/foundation/api/desktop/foundation.api b/compose/foundation/foundation/api/desktop/foundation.api index 742d10fb54a83..3b927ddcbaf68 100644 --- a/compose/foundation/foundation/api/desktop/foundation.api +++ b/compose/foundation/foundation/api/desktop/foundation.api @@ -1943,6 +1943,13 @@ public abstract interface class androidx/compose/foundation/text/contextmenu/dat public abstract fun close ()V } +public final class androidx/compose/foundation/text/contextmenu/internal/ComposableSingletons$DefaultTextContextMenuDropdownProvider_skikoKt { + public static final field INSTANCE Landroidx/compose/foundation/text/contextmenu/internal/ComposableSingletons$DefaultTextContextMenuDropdownProvider_skikoKt; + public fun ()V + public final fun getLambda$-651812147$foundation ()Lkotlin/jvm/functions/Function5; + public final fun getLambda$1837043723$foundation ()Lkotlin/jvm/functions/Function5; +} + public final class androidx/compose/foundation/text/contextmenu/modifier/TextContextMenuModifierKt { public static final fun appendTextContextMenuComponents (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;)Landroidx/compose/ui/Modifier; public static final fun filterTextContextMenuComponents (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;)Landroidx/compose/ui/Modifier; diff --git a/compose/foundation/foundation/api/foundation.klib.api b/compose/foundation/foundation/api/foundation.klib.api index 6ba2633e6c430..8a8df7ca2feb4 100644 --- a/compose/foundation/foundation/api/foundation.klib.api +++ b/compose/foundation/foundation/api/foundation.klib.api @@ -1639,6 +1639,7 @@ final val androidx.compose.foundation.shape/androidx_compose_foundation_shape_Ro final val androidx.compose.foundation.text.contextmenu.builder/androidx_compose_foundation_text_contextmenu_builder_TextContextMenuBuilderScope$stableprop // androidx.compose.foundation.text.contextmenu.builder/androidx_compose_foundation_text_contextmenu_builder_TextContextMenuBuilderScope$stableprop|#static{}androidx_compose_foundation_text_contextmenu_builder_TextContextMenuBuilderScope$stableprop[0] final val androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuComponent$stableprop // androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuComponent$stableprop|#static{}androidx_compose_foundation_text_contextmenu_data_TextContextMenuComponent$stableprop[0] final val androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuData$stableprop // androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuData$stableprop|#static{}androidx_compose_foundation_text_contextmenu_data_TextContextMenuData$stableprop[0] +final val androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuItemWithComposableLeadingIcon$stableprop // androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuItemWithComposableLeadingIcon$stableprop|#static{}androidx_compose_foundation_text_contextmenu_data_TextContextMenuItemWithComposableLeadingIcon$stableprop[0] final val androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuKeys$stableprop // androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuKeys$stableprop|#static{}androidx_compose_foundation_text_contextmenu_data_TextContextMenuKeys$stableprop[0] final val androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuSeparator$stableprop // androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuSeparator$stableprop|#static{}androidx_compose_foundation_text_contextmenu_data_TextContextMenuSeparator$stableprop[0] final val androidx.compose.foundation.text.contextmenu.modifier/androidx_compose_foundation_text_contextmenu_modifier_AddTextContextMenuDataComponentsNode$stableprop // androidx.compose.foundation.text.contextmenu.modifier/androidx_compose_foundation_text_contextmenu_modifier_AddTextContextMenuDataComponentsNode$stableprop|#static{}androidx_compose_foundation_text_contextmenu_modifier_AddTextContextMenuDataComponentsNode$stableprop[0] @@ -1773,6 +1774,7 @@ final val androidx.compose.foundation/androidx_compose_foundation_BorderStroke$s final val androidx.compose.foundation/androidx_compose_foundation_ClickableNode$stableprop // androidx.compose.foundation/androidx_compose_foundation_ClickableNode$stableprop|#static{}androidx_compose_foundation_ClickableNode$stableprop[0] final val androidx.compose.foundation/androidx_compose_foundation_CombinedClickableNode_DoubleKeyClickState$stableprop // androidx.compose.foundation/androidx_compose_foundation_CombinedClickableNode_DoubleKeyClickState$stableprop|#static{}androidx_compose_foundation_CombinedClickableNode_DoubleKeyClickState$stableprop[0] final val androidx.compose.foundation/androidx_compose_foundation_ComposeFoundationFlags$stableprop // androidx.compose.foundation/androidx_compose_foundation_ComposeFoundationFlags$stableprop|#static{}androidx_compose_foundation_ComposeFoundationFlags$stableprop[0] +final val androidx.compose.foundation/androidx_compose_foundation_ContextMenuColors$stableprop // androidx.compose.foundation/androidx_compose_foundation_ContextMenuColors$stableprop|#static{}androidx_compose_foundation_ContextMenuColors$stableprop[0] final val androidx.compose.foundation/androidx_compose_foundation_FocusableNode$stableprop // androidx.compose.foundation/androidx_compose_foundation_FocusableNode$stableprop|#static{}androidx_compose_foundation_FocusableNode$stableprop[0] final val androidx.compose.foundation/androidx_compose_foundation_FocusedBoundsObserverNode$stableprop // androidx.compose.foundation/androidx_compose_foundation_FocusedBoundsObserverNode$stableprop|#static{}androidx_compose_foundation_FocusedBoundsObserverNode$stableprop[0] final val androidx.compose.foundation/androidx_compose_foundation_InputModeFilterIndication$stableprop // androidx.compose.foundation/androidx_compose_foundation_InputModeFilterIndication$stableprop|#static{}androidx_compose_foundation_InputModeFilterIndication$stableprop[0] @@ -2119,6 +2121,7 @@ final fun androidx.compose.foundation.shape/androidx_compose_foundation_shape_Ro final fun androidx.compose.foundation.text.contextmenu.builder/androidx_compose_foundation_text_contextmenu_builder_TextContextMenuBuilderScope$stableprop_getter(): kotlin/Int // androidx.compose.foundation.text.contextmenu.builder/androidx_compose_foundation_text_contextmenu_builder_TextContextMenuBuilderScope$stableprop_getter|androidx_compose_foundation_text_contextmenu_builder_TextContextMenuBuilderScope$stableprop_getter(){}[0] final fun androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuComponent$stableprop_getter(): kotlin/Int // androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuComponent$stableprop_getter|androidx_compose_foundation_text_contextmenu_data_TextContextMenuComponent$stableprop_getter(){}[0] final fun androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuData$stableprop_getter(): kotlin/Int // androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuData$stableprop_getter|androidx_compose_foundation_text_contextmenu_data_TextContextMenuData$stableprop_getter(){}[0] +final fun androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuItemWithComposableLeadingIcon$stableprop_getter(): kotlin/Int // androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuItemWithComposableLeadingIcon$stableprop_getter|androidx_compose_foundation_text_contextmenu_data_TextContextMenuItemWithComposableLeadingIcon$stableprop_getter(){}[0] final fun androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuKeys$stableprop_getter(): kotlin/Int // androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuKeys$stableprop_getter|androidx_compose_foundation_text_contextmenu_data_TextContextMenuKeys$stableprop_getter(){}[0] final fun androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuSeparator$stableprop_getter(): kotlin/Int // androidx.compose.foundation.text.contextmenu.data/androidx_compose_foundation_text_contextmenu_data_TextContextMenuSeparator$stableprop_getter|androidx_compose_foundation_text_contextmenu_data_TextContextMenuSeparator$stableprop_getter(){}[0] final fun androidx.compose.foundation.text.contextmenu.modifier/androidx_compose_foundation_text_contextmenu_modifier_AddTextContextMenuDataComponentsNode$stableprop_getter(): kotlin/Int // androidx.compose.foundation.text.contextmenu.modifier/androidx_compose_foundation_text_contextmenu_modifier_AddTextContextMenuDataComponentsNode$stableprop_getter|androidx_compose_foundation_text_contextmenu_modifier_AddTextContextMenuDataComponentsNode$stableprop_getter(){}[0] @@ -2275,6 +2278,7 @@ final fun androidx.compose.foundation/androidx_compose_foundation_BorderStroke$s final fun androidx.compose.foundation/androidx_compose_foundation_ClickableNode$stableprop_getter(): kotlin/Int // androidx.compose.foundation/androidx_compose_foundation_ClickableNode$stableprop_getter|androidx_compose_foundation_ClickableNode$stableprop_getter(){}[0] final fun androidx.compose.foundation/androidx_compose_foundation_CombinedClickableNode_DoubleKeyClickState$stableprop_getter(): kotlin/Int // androidx.compose.foundation/androidx_compose_foundation_CombinedClickableNode_DoubleKeyClickState$stableprop_getter|androidx_compose_foundation_CombinedClickableNode_DoubleKeyClickState$stableprop_getter(){}[0] final fun androidx.compose.foundation/androidx_compose_foundation_ComposeFoundationFlags$stableprop_getter(): kotlin/Int // androidx.compose.foundation/androidx_compose_foundation_ComposeFoundationFlags$stableprop_getter|androidx_compose_foundation_ComposeFoundationFlags$stableprop_getter(){}[0] +final fun androidx.compose.foundation/androidx_compose_foundation_ContextMenuColors$stableprop_getter(): kotlin/Int // androidx.compose.foundation/androidx_compose_foundation_ContextMenuColors$stableprop_getter|androidx_compose_foundation_ContextMenuColors$stableprop_getter(){}[0] final fun androidx.compose.foundation/androidx_compose_foundation_FocusableNode$stableprop_getter(): kotlin/Int // androidx.compose.foundation/androidx_compose_foundation_FocusableNode$stableprop_getter|androidx_compose_foundation_FocusableNode$stableprop_getter(){}[0] final fun androidx.compose.foundation/androidx_compose_foundation_FocusedBoundsObserverNode$stableprop_getter(): kotlin/Int // androidx.compose.foundation/androidx_compose_foundation_FocusedBoundsObserverNode$stableprop_getter|androidx_compose_foundation_FocusedBoundsObserverNode$stableprop_getter(){}[0] final fun androidx.compose.foundation/androidx_compose_foundation_InputModeFilterIndication$stableprop_getter(): kotlin/Int // androidx.compose.foundation/androidx_compose_foundation_InputModeFilterIndication$stableprop_getter|androidx_compose_foundation_InputModeFilterIndication$stableprop_getter(){}[0] 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 61df6bab0b6b6..99b6ba74f3ac6 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 @@ -16,46 +16,18 @@ package androidx.compose.foundation -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.JPopupTextMenu +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuItemWithComposableLeadingIcon +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSession import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.derivedStateOf 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.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposePanel import androidx.compose.ui.awt.ComposeWindow -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.InputMode -import androidx.compose.ui.input.InputModeManager -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.nativeKeyCode -import androidx.compose.ui.input.key.type -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalInputModeManager -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.rememberPopupPositionProviderAtPosition import java.awt.Component import java.awt.MouseInfo @@ -65,10 +37,6 @@ import javax.swing.SwingUtilities import javax.swing.event.PopupMenuEvent import javax.swing.event.PopupMenuListener -// Design of basic representation is from Material specs: -// https://material.io/design/interaction/states.html#hover -// https://material.io/components/menus#specs - /** * Representation of a context menu that is suitable for light themes of the application. */ @@ -104,93 +72,42 @@ class DefaultContextMenuRepresentation( override fun Representation(state: ContextMenuState, items: () -> List) { val status = state.status if (status is ContextMenuState.Status.Open) { - var focusManager: FocusManager? by mutableStateOf(null) - var inputModeManager: InputModeManager? by mutableStateOf(null) - - Popup( - properties = PopupProperties(focusable = true), - onDismissRequest = { state.status = ContextMenuState.Status.Closed }, - popupPositionProvider = rememberPopupPositionProviderAtPosition( - positionPx = status.rect.center - ), - onKeyEvent = { - if (it.type == KeyEventType.KeyDown) { - when (it.key.nativeKeyCode) { - java.awt.event.KeyEvent.VK_DOWN -> { - inputModeManager!!.requestInputMode(InputMode.Keyboard) - focusManager!!.moveFocus(FocusDirection.Next) - true - } - java.awt.event.KeyEvent.VK_UP -> { - inputModeManager!!.requestInputMode(InputMode.Keyboard) - focusManager!!.moveFocus(FocusDirection.Previous) - true - } - else -> false - } - } else { - false + val session = remember(state) { + object : TextContextMenuSession { + override fun close() { + state.status = ContextMenuState.Status.Closed } - }, - ) { - focusManager = LocalFocusManager.current - inputModeManager = LocalInputModeManager.current - Column( - modifier = Modifier - .shadow(8.dp) - .background(backgroundColor) - .padding(vertical = 4.dp) - .width(IntrinsicSize.Max) - .verticalScroll(rememberScrollState()) - - ) { - items().forEach { item -> - MenuItemContent( - itemHoverColor = itemHoverColor, - onClick = { - state.status = ContextMenuState.Status.Closed - item.onClick() - } - ) { - BasicText(text = item.label, style = TextStyle(color = textColor)) - } + } + } + val components by remember { + derivedStateOf { + items().map { + TextContextMenuItemWithComposableLeadingIcon( + key = it, + label = it.label, + enabled = true, + onClick = { it.onClick() } + ) } } } - } - } -} - -@Composable -private fun MenuItemContent( - itemHoverColor: Color, - onClick: () -> Unit, - content: @Composable RowScope.() -> Unit -) { - var hovered by remember { mutableStateOf(false) } - Row( - modifier = Modifier - .clickable( - onClick = onClick, - ) - .onHover { hovered = it } - .background(if (hovered) itemHoverColor else Color.Transparent) - .fillMaxWidth() - // Preferred min and max width used during the intrinsic measurement. - .sizeIn( - minWidth = 112.dp, - maxWidth = 280.dp, - minHeight = 32.dp - ) - .padding( - PaddingValues( - horizontal = 16.dp, - vertical = 0.dp + val colors = remember(backgroundColor, textColor, itemHoverColor) { + ContextMenuColors( + backgroundColor = backgroundColor, + textColor = textColor, + iconColor = Color.Unspecified, + disabledTextColor = Color.Unspecified, + disabledIconColor = Color.Unspecified, + hoverColor = itemHoverColor, ) - ), - verticalAlignment = Alignment.CenterVertically - ) { - content() + } + DefaultOpenContextMenu( + session = session, + components = components, + popupPositionProvider = rememberPopupPositionProviderAtPosition(status.rect.center), + colors = colors, + ) + } } } @@ -247,16 +164,4 @@ class JPopupContextMenuRepresentation( } } } -} - -private fun Modifier.onHover(onHover: (Boolean) -> Unit) = pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent() - when (event.type) { - PointerEventType.Enter -> onHover(true) - PointerEventType.Exit -> onHover(false) - } - } - } -} +} \ No newline at end of file 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 c68b39dbc6629..d922fd49a60e7 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 @@ -16,6 +16,7 @@ package androidx.compose.foundation.text +import androidx.compose.foundation.ComposeFoundationFlags import androidx.compose.foundation.ContextMenuArea import androidx.compose.foundation.ContextMenuDataProvider import androidx.compose.foundation.ContextMenuItem @@ -27,9 +28,14 @@ import androidx.compose.foundation.LocalContextMenuRepresentation import androidx.compose.foundation.contextMenuOpenDetector import androidx.compose.foundation.internal.nativeClipboardHasText import androidx.compose.foundation.text.TextContextMenu.TextManager +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuKeys +import androidx.compose.foundation.text.contextmenu.internal.ProvideDefaultPlatformTextContextMenuProviders +import androidx.compose.foundation.text.contextmenu.modifier.textContextMenuGestures +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.getSelectedText import androidx.compose.foundation.text.input.internal.selection.TextFieldSelectionState import androidx.compose.foundation.text.selection.SelectionAdjustment +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionManager import androidx.compose.foundation.text.selection.TextFieldSelectionManager import androidx.compose.runtime.Composable @@ -44,8 +50,10 @@ import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.platform.LocalLocalization +import androidx.compose.ui.platform.PlatformLocalization import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.getSelectedText import java.awt.Component import javax.swing.JPopupMenu @@ -53,45 +61,74 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.launch +/** + * Context menu area for [BasicTextField] (with [TextFieldValue] argument). + */ @OptIn(ExperimentalFoundationApi::class) @Composable internal actual fun ContextMenuArea( manager: TextFieldSelectionManager, content: @Composable () -> Unit ) { - val state = remember { ContextMenuState() } - val textManager = remember(manager) { manager.textManager } - LocalTextContextMenu.current.Area(textManager, state, content) + if (ComposeFoundationFlags.isNewContextMenuEnabled) { + ProvideDefaultPlatformTextContextMenuProviders(manager.contextMenuAreaModifier, content) + } else { + val state = remember { ContextMenuState() } + val textManager = remember(manager) { manager.textManager } + LocalTextContextMenu.current.Area(textManager, state, content) + } } +/** + * Context menu area for [BasicTextField] (with [TextFieldState] argument). + */ @Composable internal actual fun ContextMenuArea( selectionState: TextFieldSelectionState, enabled: Boolean, content: @Composable () -> Unit ) { - if (!enabled) { - content() - return - } + if (ComposeFoundationFlags.isNewContextMenuEnabled) { + val modifier = + if (enabled) { + Modifier.textContextMenuGestures( + onPreShowContextMenu = { selectionState.updateClipboardEntry() } + ) + } else { + Modifier + } + ProvideDefaultPlatformTextContextMenuProviders(modifier, content) + } else { + if (!enabled) { + content() + return + } - val state = remember { ContextMenuState() } - val coroutineScope = rememberCoroutineScope() - val textManager = remember(selectionState, coroutineScope) { - selectionState.textManager(coroutineScope) + val state = remember { ContextMenuState() } + val coroutineScope = rememberCoroutineScope() + val textManager = remember(selectionState, coroutineScope) { + selectionState.textManager(coroutineScope) + } + LocalTextContextMenu.current.Area(textManager, state, content) } - LocalTextContextMenu.current.Area(textManager, state, content) } +/** + * Context menu area for [SelectionContainer]. + */ @OptIn(ExperimentalFoundationApi::class) @Composable internal actual fun ContextMenuArea( manager: SelectionManager, content: @Composable () -> Unit ) { - val state = remember { ContextMenuState() } - val textManager = remember(manager) { manager.textManager } - LocalTextContextMenu.current.Area(textManager, state, content) + if (ComposeFoundationFlags.isNewContextMenuEnabled) { + ProvideDefaultPlatformTextContextMenuProviders(manager.contextMenuAreaModifier, content) + } else { + val state = remember { ContextMenuState() } + val textManager = remember(manager) { manager.textManager } + LocalTextContextMenu.current.Area(textManager, state, content) + } } @OptIn(ExperimentalFoundationApi::class) @@ -370,4 +407,28 @@ fun TextContextMenuArea( }, content = content ) -} \ No newline at end of file +} + +/** + * The default text context menu items. + * + * @param label The label of this item + */ +internal enum class DesktopTextContextMenuItems(val key: Any, val label: (PlatformLocalization) -> String) { + Cut( + key = TextContextMenuKeys.CutKey, + label = { it.cut }, + ), + Copy( + key = TextContextMenuKeys.CopyKey, + label = { it.copy }, + ), + Paste( + key = TextContextMenuKeys.PasteKey, + label = { it.paste }, + ), + SelectAll( + key = TextContextMenuKeys.SelectAllKey, + label = { it.selectAll }, + ) +} diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/contextmenu/builder/TextContextMenuBuilderScope.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/contextmenu/builder/TextContextMenuBuilderScope.desktop.kt new file mode 100644 index 0000000000000..51ca52708963e --- /dev/null +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/contextmenu/builder/TextContextMenuBuilderScope.desktop.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text.contextmenu.builder + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuItemWithComposableLeadingIcon +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSession +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * Adds an item to the list of text context menu components. + * + * @param key A unique key that identifies this item. Used to identify context menu items in the + * context menu. It is advisable to use a `data object` as a key here. + * @param label string to display as the text of the item. + * @param leadingIcon Icon that precedes the label in the context menu. + * @param onClick Action to perform upon the item being clicked/pressed. + */ +@ExperimentalFoundationApi +fun TextContextMenuBuilderScope.item( + key: Any, + label: String, + enabled: Boolean = true, + leadingIcon: (@Composable (color: Color) -> Unit)? = null, + onClick: TextContextMenuSession.() -> Unit, +) { + addComponent(TextContextMenuItemWithComposableLeadingIcon(key, label, enabled, leadingIcon, onClick)) +} diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/PlatformDefaultTextContextMenuProviders.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/PlatformDefaultTextContextMenuProviders.desktop.kt new file mode 100644 index 0000000000000..f346afb863162 --- /dev/null +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/PlatformDefaultTextContextMenuProviders.desktop.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text.contextmenu.internal + +import androidx.compose.foundation.text.contextmenu.provider.LocalTextContextMenuDropdownProvider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal actual fun ProvideDefaultPlatformTextContextMenuProviders( + modifier: Modifier, + content: @Composable () -> Unit +) { + val dropdownDefined = LocalTextContextMenuDropdownProvider.current != null + if (!dropdownDefined) { + ProvideDefaultTextContextMenuDropdown(modifier, content) + } +} + diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/contextmenu/modifier/TextContextMenuModifier.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/contextmenu/modifier/TextContextMenuModifier.desktop.kt new file mode 100644 index 0000000000000..2b7f9357a4f00 --- /dev/null +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/contextmenu/modifier/TextContextMenuModifier.desktop.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text.contextmenu.modifier + +import androidx.compose.foundation.text.contextmenu.builder.TextContextMenuBuilderScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.LocalLocalization +import androidx.compose.ui.platform.PlatformLocalization + +internal fun Modifier.addTextContextMenuComponentsWithLocalization( + builder: TextContextMenuBuilderScope.(PlatformLocalization) -> Unit, +): Modifier = this then AddTextContextMenuDataComponentsWithLocalizationElement(builder) + +private class AddTextContextMenuDataComponentsWithLocalizationElement( + private val builder: TextContextMenuBuilderScope.(PlatformLocalization) -> Unit, +) : ModifierNodeElement() { + override fun create(): AddTextContextMenuDataComponentsWithLocalizationNode = + AddTextContextMenuDataComponentsWithLocalizationNode(builder) + + override fun update(node: AddTextContextMenuDataComponentsWithLocalizationNode) { + node.builder = builder + } + + override fun InspectorInfo.inspectableProperties() { + name = "addTextContextMenuDataComponentsWithLocalization" + properties["builder"] = builder + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is AddTextContextMenuDataComponentsWithLocalizationElement) return false + + if (builder !== other.builder) return false + + return true + } + + override fun hashCode(): Int = builder.hashCode() +} + +private class AddTextContextMenuDataComponentsWithLocalizationNode( + var builder: TextContextMenuBuilderScope.(PlatformLocalization) -> Unit, +) : DelegatingNode(), CompositionLocalConsumerModifierNode { + init { + delegate(AddTextContextMenuDataComponentsNode { builder(currentValueOf(LocalLocalization)) }) + } +} diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.desktop.kt new file mode 100644 index 0000000000000..0cac7ff76d33d --- /dev/null +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.desktop.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text.input.internal.selection + +import androidx.compose.foundation.text.DesktopTextContextMenuItems +import androidx.compose.foundation.text.DesktopTextContextMenuItems.Copy +import androidx.compose.foundation.text.DesktopTextContextMenuItems.Cut +import androidx.compose.foundation.text.DesktopTextContextMenuItems.Paste +import androidx.compose.foundation.text.DesktopTextContextMenuItems.SelectAll +import androidx.compose.foundation.text.contextmenu.builder.TextContextMenuBuilderScope +import androidx.compose.foundation.text.contextmenu.builder.item +import androidx.compose.foundation.text.contextmenu.modifier.addTextContextMenuComponentsWithLocalization +import androidx.compose.ui.Modifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch + +internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( + state: TextFieldSelectionState, + coroutineScope: CoroutineScope, +): Modifier = addTextContextMenuComponentsWithLocalization { localization -> + fun TextContextMenuBuilderScope.textFieldItem( + item: DesktopTextContextMenuItems, + enabled: Boolean, + onClick: () -> Unit, + ) { + item( + key = item.key, + label = item.label(localization), + enabled = enabled, + onClick = { + onClick() + close() + } + ) + } + + fun TextContextMenuBuilderScope.textFieldSuspendItem( + item: DesktopTextContextMenuItems, + enabled: Boolean, + onClick: suspend () -> Unit, + ) { + textFieldItem(item, enabled) { + coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { onClick() } + } + } + + with(state) { + separator() + textFieldSuspendItem(Cut, enabled = canCut()) { cut() } + textFieldSuspendItem(Copy, enabled = canCopy()) { copy(cancelSelection = false) } + textFieldSuspendItem(Paste, enabled = canPaste()) { paste() } + textFieldItem(SelectAll, enabled = canSelectAll()) { selectAll() } + separator() + } +} diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.desktop.kt index 15ba6610316f1..c88c4451ab233 100644 --- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.desktop.kt +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/selection/SelectionManager.desktop.kt @@ -18,6 +18,12 @@ package androidx.compose.foundation.text.selection import androidx.compose.foundation.DesktopPlatform import androidx.compose.foundation.text.MappedKeys +import androidx.compose.foundation.text.DesktopTextContextMenuItems +import androidx.compose.foundation.text.DesktopTextContextMenuItems.Copy +import androidx.compose.foundation.text.DesktopTextContextMenuItems.SelectAll +import androidx.compose.foundation.text.contextmenu.builder.TextContextMenuBuilderScope +import androidx.compose.foundation.text.contextmenu.builder.item +import androidx.compose.foundation.text.contextmenu.modifier.addTextContextMenuComponentsWithLocalization import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.isCtrlPressed @@ -38,7 +44,36 @@ internal actual fun isCopyKeyEvent(keyEvent: KeyEvent) = */ internal actual fun Modifier.selectionMagnifier(manager: SelectionManager): Modifier = this -// TODO https://youtrack.jetbrains.com/issue/CMP-7819 internal actual fun Modifier.addSelectionContainerTextContextMenuComponents( - selectionManager: SelectionManager -): Modifier = this + selectionManager: SelectionManager, +): Modifier = addTextContextMenuComponentsWithLocalization { localization -> + fun TextContextMenuBuilderScope.selectionContainerItem( + item: DesktopTextContextMenuItems, + enabled: Boolean, + closePredicate: (() -> Boolean)? = null, + onClick: () -> Unit + ) { + item( + key = item.key, + label = item.label(localization), + enabled = enabled, + onClick = { + onClick() + if (closePredicate?.invoke() != false) close() + } + ) + } + + with(selectionManager) { + separator() + selectionContainerItem(Copy, enabled = isNonEmptySelection()) { copy() } + selectionContainerItem( + item = SelectAll, + enabled = !isEntireContainerSelected(), + closePredicate = { !showToolbar || !isInTouchMode }, + ) { + selectAll() + } + separator() + } +} diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.desktop.kt index 477c8db9a09e4..ec5ce49d09709 100644 --- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.desktop.kt +++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/selection/TextFieldSelectionManager.desktop.kt @@ -16,10 +16,18 @@ package androidx.compose.foundation.text.selection +import androidx.compose.foundation.text.DesktopTextContextMenuItems +import androidx.compose.foundation.text.DesktopTextContextMenuItems.Copy +import androidx.compose.foundation.text.DesktopTextContextMenuItems.Cut +import androidx.compose.foundation.text.DesktopTextContextMenuItems.Paste +import androidx.compose.foundation.text.DesktopTextContextMenuItems.SelectAll +import androidx.compose.foundation.text.contextmenu.builder.TextContextMenuBuilderScope +import androidx.compose.foundation.text.contextmenu.builder.item +import androidx.compose.foundation.text.contextmenu.modifier.addTextContextMenuComponentsWithLocalization import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.awtEventOrNull -import androidx.compose.ui.input.pointer.PointerEvent import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch /** * Magnification is not supported on desktop. @@ -33,9 +41,42 @@ internal actual fun TextFieldSelectionManager.isSelectionHandleInVisibleBound( isStartHandle: Boolean ): Boolean = isSelectionHandleInVisibleBoundDefault(isStartHandle) -// TODO: https://youtrack.jetbrains.com/issue/CMP-7819 internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( manager: TextFieldSelectionManager, coroutineScope: CoroutineScope, -): Modifier = this +): Modifier = addTextContextMenuComponentsWithLocalization { localization -> + fun TextContextMenuBuilderScope.textFieldItem( + item: DesktopTextContextMenuItems, + enabled: Boolean, + onClick: () -> Unit, + ) { + item( + key = item.key, + label = item.label(localization), + enabled = enabled, + onClick = { + onClick() + close() + } + ) + } + fun TextContextMenuBuilderScope.textFieldSuspendItem( + item: DesktopTextContextMenuItems, + enabled: Boolean, + onClick: suspend () -> Unit, + ) { + textFieldItem(item, enabled) { + coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { onClick() } + } + } + + with(manager) { + separator() + textFieldSuspendItem(Cut, enabled = canCut()) { cut() } + textFieldSuspendItem(Copy, enabled = canCopy()) { copy(cancelSelection = false) } + textFieldSuspendItem(Paste, enabled = canPaste()) { paste() } + textFieldItem(SelectAll, enabled = canSelectAll()) { selectAll() } + separator() + } +} diff --git a/compose/foundation/foundation/src/jsNativeMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/ProvideDefaultPlatformTextContextMenuProviders.jsNative.kt b/compose/foundation/foundation/src/jsNativeMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/ProvideDefaultPlatformTextContextMenuProviders.jsNative.kt new file mode 100644 index 0000000000000..1ca98578603e6 --- /dev/null +++ b/compose/foundation/foundation/src/jsNativeMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/ProvideDefaultPlatformTextContextMenuProviders.jsNative.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text.contextmenu.internal + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal actual fun ProvideDefaultPlatformTextContextMenuProviders( + modifier: Modifier, + content: @Composable () -> Unit +) { + // TODO: https://youtrack.jetbrains.com/issue/CMP-7819 +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.jsWasm.kt b/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.jsWasm.kt index a00b8b9827d41..ce0005c14d5c5 100644 --- a/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.jsWasm.kt +++ b/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.jsWasm.kt @@ -19,7 +19,9 @@ package androidx.compose.foundation.text.input.internal.selection import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.text.TextDragObserver import androidx.compose.foundation.text.selection.MouseSelectionObserver +import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerInputScope +import kotlinx.coroutines.CoroutineScope /** Runs platform-specific text tap gestures logic. */ internal actual suspend fun PointerInputScope.detectTextFieldTapGestures( @@ -34,4 +36,10 @@ internal actual suspend fun PointerInputScope.getTextFieldSelectionGestures( selectionState: TextFieldSelectionState, mouseSelectionObserver: MouseSelectionObserver, textDragObserver: TextDragObserver -) = defaultTextFieldSelectionGestures(mouseSelectionObserver, textDragObserver) \ No newline at end of file +) = defaultTextFieldSelectionGestures(mouseSelectionObserver, textDragObserver) + +// TODO: https://youtrack.jetbrains.com/issue/CMP-8433/web-Adopt-new-context-menu-API +internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( + state: TextFieldSelectionState, + coroutineScope: CoroutineScope +): Modifier = this \ No newline at end of file diff --git a/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.macos.kt b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.macos.kt index a00b8b9827d41..ed73f53d25756 100644 --- a/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.macos.kt +++ b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.macos.kt @@ -19,7 +19,9 @@ package androidx.compose.foundation.text.input.internal.selection import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.text.TextDragObserver import androidx.compose.foundation.text.selection.MouseSelectionObserver +import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerInputScope +import kotlinx.coroutines.CoroutineScope /** Runs platform-specific text tap gestures logic. */ internal actual suspend fun PointerInputScope.detectTextFieldTapGestures( @@ -34,4 +36,9 @@ internal actual suspend fun PointerInputScope.getTextFieldSelectionGestures( selectionState: TextFieldSelectionState, mouseSelectionObserver: MouseSelectionObserver, textDragObserver: TextDragObserver -) = defaultTextFieldSelectionGestures(mouseSelectionObserver, textDragObserver) \ No newline at end of file +) = defaultTextFieldSelectionGestures(mouseSelectionObserver, textDragObserver) + +internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( + state: TextFieldSelectionState, + coroutineScope: CoroutineScope +): Modifier = this \ No newline at end of file diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.skiko.kt new file mode 100644 index 0000000000000..b9ce13c58ac56 --- /dev/null +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.skiko.kt @@ -0,0 +1,267 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuComponent +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuItemWithComposableLeadingIcon +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSeparator +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSession +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +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.draw.shadow +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.InputModeManager +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.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties + +// Design of basic representation is from Material specs: +// https://material.io/design/interaction/states.html#hover +// https://material.io/components/menus#specs + +@Composable +internal fun DefaultOpenContextMenu( + session: TextContextMenuSession, + components: List, + popupPositionProvider: PopupPositionProvider, + colors: ContextMenuColors = DefaultContextMenuColors, +) { + var focusManager: FocusManager? by mutableStateOf(null) + var inputModeManager: InputModeManager? by mutableStateOf(null) + + Popup( + properties = PopupProperties(focusable = true), + onDismissRequest = { session.close() }, + popupPositionProvider = popupPositionProvider, + onKeyEvent = { + if (it.type == KeyEventType.KeyDown) { + when (it.key) { + Key.DirectionDown -> { + inputModeManager!!.requestInputMode(InputMode.Keyboard) + focusManager!!.moveFocus(FocusDirection.Next) + true + } + Key.DirectionUp -> { + inputModeManager!!.requestInputMode(InputMode.Keyboard) + focusManager!!.moveFocus(FocusDirection.Previous) + true + } + else -> false + } + } else { + false + } + }, + ) { + focusManager = LocalFocusManager.current + inputModeManager = LocalInputModeManager.current + Column( + modifier = Modifier + .shadow(8.dp) + .background(colors.backgroundColor) + .padding(vertical = 4.dp) + .width(IntrinsicSize.Max) + .verticalScroll(rememberScrollState()) + ) { + components.forEach { component -> + when (component) { + is TextContextMenuSeparator -> MenuSeparator(colors.textColor) + is TextContextMenuItemWithComposableLeadingIcon -> { + MenuItemContent( + itemHoverColor = colors.hoverColor, + onClick = { component.onClick(session) }, + ) { + component.leadingIcon?.let { icon -> + icon(colors.resolveIconColor(component.enabled)) + } + BasicText( + text = component.label, + style = TextStyle(colors.resolveTextColor(component.enabled)) + ) + } + } + } + } + } + } +} + +@Composable +private fun MenuSeparator(color: Color) { + Box( + modifier = + Modifier.padding(vertical = 8.dp) + .fillMaxWidth() + .height(1.dp) + .background(color) + ) +} + +@Composable +private fun MenuItemContent( + itemHoverColor: Color, + onClick: () -> Unit, + content: @Composable RowScope.() -> Unit +) { + var hovered by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .clickable( + onClick = onClick, + ) + .onHover { hovered = it } + .background(if (hovered) itemHoverColor else Color.Transparent) + .fillMaxWidth() + // Preferred min and max width used during the intrinsic measurement. + .sizeIn( + minWidth = 112.dp, + maxWidth = 280.dp, + minHeight = 32.dp + ) + .padding( + PaddingValues( + horizontal = 16.dp, + vertical = 0.dp + ) + ), + verticalAlignment = Alignment.CenterVertically + ) { + content() + } +} + +private fun Modifier.onHover(onHover: (Boolean) -> Unit) = pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + when (event.type) { + PointerEventType.Enter -> onHover(true) + PointerEventType.Exit -> onHover(false) + } + } + } +} + +private const val DisabledAlpha = 0.38f + +internal val DefaultContextMenuColors = + ContextMenuColors( + backgroundColor = Color.White, + textColor = Color.Black, + iconColor = Color.Black, + disabledTextColor = Color.Black.copy(alpha = DisabledAlpha), + disabledIconColor = Color.Black.copy(alpha = DisabledAlpha), + hoverColor = Color.Black.copy(alpha = 0.04f), + ) + + +/** + * Colors to apply to the context menu. + * + * @param backgroundColor Color of the background in the context menu + * @param textColor Color of the text in context menu items + * @param iconColor Color of the icon in context menu items + * @param disabledTextColor Color of disabled text in context menu items + * @param disabledIconColor Color of disabled icon in context menu items + */ +@Stable +internal class ContextMenuColors( + val backgroundColor: Color, + val textColor: Color, + val iconColor: Color, + val disabledTextColor: Color, + val disabledIconColor: Color, + val hoverColor: Color, +) { + + /** + * Returns the text color to use in the given enabled state. + */ + fun resolveTextColor(enabled: Boolean): Color = + if (enabled) textColor else disabledTextColor + + /** + * Returns the icon color to use in the given enabled state. + */ + fun resolveIconColor(enabled: Boolean): Color = + if (enabled) iconColor else disabledIconColor + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || other !is ContextMenuColors) return false + + if (this.backgroundColor != other.backgroundColor) return false + if (this.textColor != other.textColor) return false + if (this.iconColor != other.iconColor) return false + if (this.disabledTextColor != other.disabledTextColor) return false + if (this.disabledIconColor != other.disabledIconColor) return false + if (this.hoverColor != other.hoverColor) return false + + return true + } + + override fun hashCode(): Int { + var result = backgroundColor.hashCode() + result = 31 * result + textColor.hashCode() + result = 31 * result + iconColor.hashCode() + result = 31 * result + disabledTextColor.hashCode() + result = 31 * result + disabledIconColor.hashCode() + result = 31 * result + hoverColor.hashCode() + return result + } + + override fun toString(): String = + "ContextMenuColors(" + + "backgroundColor=$backgroundColor, " + + "textColor=$textColor, " + + "iconColor=$iconColor, " + + "disabledTextColor=$disabledTextColor, " + + "disabledIconColor=$disabledIconColor, " + + "hoverColor=$hoverColor" + + ")" +} diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/contextmenu/data/TextContextMenuData.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/contextmenu/data/TextContextMenuData.skiko.kt new file mode 100644 index 0000000000000..0ca09b7da0719 --- /dev/null +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/contextmenu/data/TextContextMenuData.skiko.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text.contextmenu.data + +import androidx.compose.foundation.text.contextmenu.modifier.filterTextContextMenuComponents +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** + * A [TextContextMenuComponent] that represents a clickable item with a label in a context menu. + * + * @param key A unique key that identifies this component, mainly for use in filtering a component + * in [Modifier.filterTextContextMenuComponents][filterTextContextMenuComponents]. It is advisable + * to use a `data object` as a key here. + * @param label The label text to be shown in the context menu. + * @param leadingIcon Icon that precedes the label in the context menu. + * @param onClick The action to be performed when this item is clicked. Call + * [TextContextMenuSession.close] on the [TextContextMenuSession] receiver to close the context + * menu item as a result of the click. + */ +// TODO(grantapher-cm-api-publicize) Make class public +internal class TextContextMenuItemWithComposableLeadingIcon +internal constructor( + key: Any, + val label: String, + val enabled: Boolean, + val leadingIcon: (@Composable (color: Color) -> Unit)? = null, + val onClick: TextContextMenuSession.() -> Unit, +) : TextContextMenuComponent(key) { + override fun toString(): String = + "TextContextMenuItem(key=$key, label=\"$label\", enabled=$enabled)" +} diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/DefaultTextContextMenuDropdownProvider.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/DefaultTextContextMenuDropdownProvider.skiko.kt new file mode 100644 index 0000000000000..f2c5812979a3d --- /dev/null +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/DefaultTextContextMenuDropdownProvider.skiko.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation.text.contextmenu.internal + +import androidx.compose.foundation.DefaultOpenContextMenu +import androidx.compose.foundation.contextmenu.ContextMenuPopupPositionProvider +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSession +import androidx.compose.foundation.text.contextmenu.provider.LocalTextContextMenuDropdownProvider +import androidx.compose.foundation.text.contextmenu.provider.ProvideBasicTextContextMenu +import androidx.compose.foundation.text.contextmenu.provider.TextContextMenuDataProvider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.round +import androidx.compose.ui.window.PopupPositionProvider + +// TODO: This is (mostly) a copy of DefaultTextContextMenuDropdownProvider.android.kt; we should +// move it to common and upstream +// https://youtrack.jetbrains.com/issue/CMP-8453/Commonize-and-upstream-shared-code-in-new-context-menu + +// TODO(grantapher) Consider making public. +@Composable +internal fun ProvideDefaultTextContextMenuDropdown( + content: @Composable () -> Unit +) { + ProvideBasicTextContextMenu( + providableCompositionLocal = LocalTextContextMenuDropdownProvider, + contextMenu = { session, dataProvider, anchorLayoutCoordinates -> + OpenContextMenu(session, dataProvider, anchorLayoutCoordinates) + }, + content = content + ) +} + +@Composable +internal fun ProvideDefaultTextContextMenuDropdown( + modifier: Modifier, + content: @Composable () -> Unit +) { + ProvideBasicTextContextMenu( + modifier = modifier, + providableCompositionLocal = LocalTextContextMenuDropdownProvider, + contextMenu = { session, dataProvider, anchorLayoutCoordinates -> + OpenContextMenu(session, dataProvider, anchorLayoutCoordinates) + }, + content = content + ) +} + +@Composable +private fun OpenContextMenu( + session: TextContextMenuSession, + dataProvider: TextContextMenuDataProvider, + anchorLayoutCoordinates: () -> LayoutCoordinates, +) { + val popupPositionProvider = + remember(dataProvider) { + MaintainWindowPositionPopupPositionProvider( + ContextMenuPopupPositionProvider({ + dataProvider.position(anchorLayoutCoordinates()).round() + }) + ) + } + val data by remember(dataProvider) { derivedStateOf(dataProvider::data) } + DefaultOpenContextMenu( + session = session, + components = data.components, + popupPositionProvider = popupPositionProvider, + ) +} + +/** + * Delegates to the [popupPositionProvider], but re-uses the previous calculated position if the + * only change is the `anchorBounds` in the window. This ensures that anchor layout movement such as + * scrolls do not cause the popup to move, but other relevant layout changes do move the popup. + * + * We do want to re-calculate a new position for any `windowSize`, `layoutDirection`, and + * `popupContentSize` changes since they may make the previous popup position un-viable. + */ +// TODO(grantapher) Consider making public. +private class MaintainWindowPositionPopupPositionProvider( + val popupPositionProvider: PopupPositionProvider +) : PopupPositionProvider { + var previousWindowSize: IntSize? = null + var previousLayoutDirection: LayoutDirection? = null + var previousPopupContentSize: IntSize? = null + + var previousPosition: IntOffset? = null + + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val position = previousPosition + if ( + position != null && + previousWindowSize == windowSize && + previousLayoutDirection == layoutDirection && + previousPopupContentSize == popupContentSize + ) { + return position + } + + val newPosition = + popupPositionProvider.calculatePosition( + anchorBounds, + windowSize, + layoutDirection, + popupContentSize, + ) + + previousWindowSize = windowSize + previousLayoutDirection = layoutDirection + previousPopupContentSize = popupContentSize + previousPosition = newPosition + return newPosition + } +} diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/ProvideDefaultPlatformTextContextMenuProviders.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/ProvideDefaultPlatformTextContextMenuProviders.kt index 6005c8ea4e894..1eee863f71469 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/ProvideDefaultPlatformTextContextMenuProviders.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/contextmenu/internal/ProvideDefaultPlatformTextContextMenuProviders.kt @@ -25,9 +25,7 @@ import androidx.compose.ui.Modifier // https://android.googlesource.com/platform/frameworks/support/+/d8bc9d81dffa35162626e45ee68d4a7e271c6ada @Composable -internal fun ProvideDefaultPlatformTextContextMenuProviders( +internal expect fun ProvideDefaultPlatformTextContextMenuProviders( modifier: Modifier = Modifier, content: @Composable () -> Unit, -) { - // TODO: https://youtrack.jetbrains.com/issue/CMP-7819 -} +) diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.skiko.kt index 25621d8f04807..01038682fa33e 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.skiko.kt @@ -24,15 +24,7 @@ import androidx.compose.foundation.text.TextContextMenuItems import androidx.compose.foundation.text.TextContextMenuItems.* import androidx.compose.foundation.text.TextItem import androidx.compose.runtime.State -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.Clipboard -import kotlinx.coroutines.CoroutineScope - -// TODO: https://youtrack.jetbrains.com/issue/CMP-7757 -internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( - state: TextFieldSelectionState, - coroutineScope: CoroutineScope -): Modifier = this internal fun TextFieldSelectionState.contextMenuBuilder( state: ContextMenuState, diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt index 1df1d3b3f6bd0..c9809db2a5402 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/text/input/internal/selection/TextFieldSelectionState.uikit.kt @@ -44,6 +44,7 @@ import androidx.compose.foundation.text.selection.SelectionAdjustment import androidx.compose.foundation.text.selection.isPrecisePointer import androidx.compose.foundation.text.selection.mouseSelectionBtf2 import androidx.compose.foundation.text.selection.touchSelectionFirstPress +import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -52,6 +53,7 @@ import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.isPrimaryPressed import androidx.compose.ui.text.TextRange import androidx.compose.ui.util.fastAll +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -363,3 +365,9 @@ private class UIKitTextFieldTextDragObserver( textFieldSelectionState.updateHandleDragging(Handle.Cursor, currentDragPosition) } } + +// TODO: https://youtrack.jetbrains.com/issue/CMP-8431/iOS-Adopt-new-context-menu-API +internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents( + state: TextFieldSelectionState, + coroutineScope: CoroutineScope +): Modifier = this \ No newline at end of file