Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -496,15 +496,18 @@ private fun notifyAboutContextMenuItems(
@Composable
private fun startObservingSelectionChanges(
context: UIKitNativeTextInputContext,
selectionProvider: () -> TextRange,
onSelectionChanged: () -> Unit
itemsStateProvider: () -> ContextMenuItemsState,
) {
LaunchedEffect(selectionProvider) {
snapshotFlow { if (context.usingNativeTextInput()) selectionProvider() else null }
.filterNotNull()
.collect {
onSelectionChanged()
}
LaunchedEffect(itemsStateProvider) {
snapshotFlow { itemsStateProvider() }.collect {
context.updateNativeTextInputEditMenuState(
copy = it.copy,
paste = it.paste,
cut = it.cut,
selectAll = it.selectAll,
customActions = it.customActions
)
}
}
}

Expand All @@ -521,55 +524,76 @@ private fun startNotifyingAboutContextMenuItems(
manager: TextFieldSelectionManager,
nativeTextInputContext: UIKitNativeTextInputContext,
) {
LaunchedEffect(manager) {
manager.updateClipboardEntry()
}
val scope = rememberCoroutineScope()
startObservingSelectionChanges(
nativeTextInputContext,
selectionProvider = { manager.value.selection },
onSelectionChanged = {
nativeTextInputContext.updateNativeTextInputEditMenuState(
copy = if (manager.isCopyAllowed()) ({ manager.copy(cancelSelection = false) }) else null,
paste = if (manager.canShowPasteMenuItem()) ({ manager.paste() }) else null,
cut = if (manager.canShowCutMenuItem()) ({ manager.cut() }) else null,
selectAll = if (manager.canShowSelectAllMenuItem()) ({ manager.selectAll() }) else null,
itemsStateProvider = {
fun editBlock(isEnabled: Boolean, action: () -> Unit): (() -> Unit)? {
return if (isEnabled) {
{
action()
scope.launch {
manager.updateClipboardEntry()
}
}
} else {
null
}
}
ContextMenuItemsState(
copy = editBlock(manager.isCopyAllowed()) { manager.copy(cancelSelection = false) },
paste = editBlock(manager.canShowPasteMenuItem()) { manager.paste() },
cut = editBlock(manager.canShowCutMenuItem()) { manager.cut() },
selectAll = editBlock(manager.canShowSelectAllMenuItem()) { manager.selectAll() },
customActions = emptyList()
)
})
}
)
}

/**
* Starts notifying the native iOS input system about the available context menu items (isNewContextMenu = false) in [BasicTextField] (with [TextFieldState] argument)
*
* @param selectionState The current state of the text field selection, including selection bounds
* @param state The current state of the text field selection, including selection bounds
* and related actions.
* @param nativeTextInputContext The UIKitNativeTextInputContext instance used to update the edit menu state
* with actions.
*/
@OptIn(InternalComposeUiApi::class)
@Composable
private fun startNotifyingAboutContextMenuItems(
selectionState: TextFieldSelectionState,
state: TextFieldSelectionState,
nativeTextInputContext: UIKitNativeTextInputContext,
) {
LaunchedEffect(state) {
state.updateClipboardEntry()
}
// this should be the same scope as at the root of BasicTextField
val coroutineScope = rememberCoroutineScope()
startObservingSelectionChanges(
nativeTextInputContext,
selectionProvider = { selectionState.textFieldState.visualText.selection },
onSelectionChanged = {
val copyBlock: () -> Unit =
{ coroutineScope.launch { selectionState.copy(cancelSelection = false) } }
val pasteBlock: () -> Unit = { coroutineScope.launch { selectionState.paste() } }
val cutBlock: () -> Unit = { coroutineScope.launch { selectionState.cut() } }
val selectAllBlock: () -> Unit = {
coroutineScope.launch {
selectionState.selectAll()
itemsStateProvider = {
fun editBlock(isEnabled: Boolean, action: suspend () -> Unit): (() -> Unit)? {
return if (isEnabled) {
{
coroutineScope.launch {
action()
state.updateClipboardEntry()
}
}
} else {
null
}
}

nativeTextInputContext.updateNativeTextInputEditMenuState(
copy = if (selectionState.canShowCopyMenuItem()) (copyBlock) else null,
paste = if (selectionState.canShowPasteMenuItem()) (pasteBlock) else null,
cut = if (selectionState.canShowCutMenuItem()) (cutBlock) else null,
selectAll = if (selectionState.canShowSelectAllMenuItem()) (selectAllBlock) else null,
ContextMenuItemsState(
copy = editBlock(state.canShowCopyMenuItem()) { state.copy(cancelSelection = false) },
paste = editBlock(state.canShowPasteMenuItem()) { state.paste() },
cut = editBlock(state.canShowCutMenuItem()) { state.cut() },
selectAll = editBlock(state.canShowSelectAllMenuItem()) { state.selectAll() },
customActions = emptyList()
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright 2026 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

import androidx.compose.ui.input.key.KeyEvent
import org.jetbrains.skiko.OS

internal actual val platformDefaultKeyMapping: KeyMapping = createPlatformDefaultKeyMapping()

internal fun createPlatformDefaultKeyMapping(): KeyMapping {
val keyMapping = createMacOsDefaultKeyMapping()
return object : KeyMapping {
override fun map(event: KeyEvent): KeyCommand? {
return when (val command = keyMapping.map(event)) {
// UITextInput is used to handle clipboard events
KeyCommand.COPY, KeyCommand.CUT, KeyCommand.PASTE, KeyCommand.SELECT_ALL -> null
else -> command
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ import androidx.compose.foundation.text.input.internal.selection.TextToolbarStat
import androidx.compose.foundation.text.selection.MouseSelectionObserver
import androidx.compose.foundation.text.selection.SelectionAdjustment
import androidx.compose.foundation.text.selection.awaitSelectionGestures
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.isSpecified
Expand Down Expand Up @@ -331,8 +334,8 @@ private class UIKitTextFieldTextDragObserver(
}

internal actual class ClipboardPasteState actual constructor(private val clipboard: Clipboard) {
private var _hasClip = false
private var _hasText = false
private var _hasClip by mutableStateOf(false)
private var _hasText by mutableStateOf(false)

actual val hasText: Boolean get() = _hasText
actual val hasClip: Boolean get() = _hasClip
Expand Down Expand Up @@ -362,6 +365,7 @@ internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents(
coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
onClick()
close()
state.updateClipboardEntry()
}
})
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlin.math.max
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch

internal actual fun Modifier.textFieldMagnifier(
manager: TextFieldSelectionManager
Expand Down Expand Up @@ -183,6 +184,9 @@ internal actual fun Modifier.addBasicTextFieldTextContextMenuComponents(
onClick = {
onClick()
close()
coroutineScope.launch {
manager.updateClipboardEntry()
}
})
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 The Android Open Source Project
* Copyright 2026 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.
Expand All @@ -16,4 +16,4 @@

package androidx.compose.foundation.text

internal actual val platformDefaultKeyMapping: KeyMapping = createMacOsDefaultKeyMapping()
internal actual val platformDefaultKeyMapping: KeyMapping = createMacOsDefaultKeyMapping()
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
selectAll:(void (^)(void))selectAllBlock
customActions:(NSArray<CMPEditMenuCustomAction *> *)customActions;

- (void)updateAvailableSystemActions:(void (^)(void))copyBlock
cut:(void (^)(void))cutBlock
paste:(void (^)(void))pasteBlock
selectAll:(void (^)(void))selectAllBlock;

- (void)hideEditMenu;

- (NSTimeInterval)editMenuDelay;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,19 @@ @interface CMPEditMenuView() <UIEditMenuInteractionDelegate>

@property (weak, nonatomic, nullable) UIView *rootView;

// Used for context menu
@property (copy, nonatomic, nullable) void (^copyBlock)(void);
@property (copy, nonatomic, nullable) void (^cutBlock)(void);
@property (copy, nonatomic, nullable) void (^pasteBlock)(void);
@property (copy, nonatomic, nullable) void (^selectAllBlock)(void);
@property (copy, nonatomic, nullable) NSArray<CMPEditMenuCustomAction *> *customActions;

// Used for hotkeys and other operations
@property (copy, nonatomic, nullable) void (^systemCopyBlock)(void);
@property (copy, nonatomic, nullable) void (^systemCutBlock)(void);
@property (copy, nonatomic, nullable) void (^systemPasteBlock)(void);
@property (copy, nonatomic, nullable) void (^systemSelectAllBlock)(void);

@property (strong, nonatomic, nullable) dispatch_block_t showContextMenuBlock;
@property (strong, nonatomic, nullable) dispatch_block_t presentInteractionBlock;

Expand Down Expand Up @@ -141,6 +148,16 @@ - (void)showEditMenuAtRect:(CGRect)targetRect
}
}

- (void)updateAvailableSystemActions:(void (^)(void))copyBlock
cut:(void (^)(void))cutBlock
paste:(void (^)(void))pasteBlock
selectAll:(void (^)(void))selectAllBlock {
self.systemCopyBlock = copyBlock;
self.systemCutBlock = cutBlock;
self.systemPasteBlock = pasteBlock;
self.systemSelectAllBlock = selectAllBlock;
}

- (void)didMoveToWindow {
[super didMoveToWindow];

Expand Down Expand Up @@ -286,6 +303,12 @@ - (void)hideEditMenu {
[self cancelShowMenuController];
[[UIMenuController sharedMenuController] hideMenu];
}

self.copyBlock = nil;
self.cutBlock = nil;
self.pasteBlock = nil;
self.selectAllBlock = nil;
self.customActions = @[];
}

- (BOOL)contextMenuItemsChangedCopy:(void (^)(void))copyBlock
Expand All @@ -302,16 +325,16 @@ - (BOOL)contextMenuItemsChangedCopy:(void (^)(void))copyBlock

- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
if (@selector(copy:) == action) {
return self.copyBlock != nil;
return self.copyBlock != nil || self.systemCopyBlock != nil;
}
if (@selector(paste:) == action) {
return self.pasteBlock != nil;
return self.pasteBlock != nil || self.systemPasteBlock != nil;
}
if (@selector(cut:) == action) {
return self.cutBlock != nil;
return self.cutBlock != nil || self.systemCutBlock != nil;
}
if (@selector(selectAll:) == action) {
return self.selectAllBlock != nil;
return self.selectAllBlock != nil || self.systemSelectAllBlock != nil;
}

if (@selector(customAction0:) == action) return self.customActions.count > 0;
Expand All @@ -331,24 +354,32 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
- (void)copy:(id)sender {
if (self.copyBlock != nil) {
self.copyBlock();
} else if (self.systemCopyBlock != nil) {
self.systemCopyBlock();
}
}

- (void)paste:(id)sender {
if (self.pasteBlock != nil) {
self.pasteBlock();
} else if (self.systemPasteBlock != nil) {
self.systemPasteBlock();
}
}

- (void)cut:(id)sender {
if (self.cutBlock != nil) {
self.cutBlock();
} else if (self.systemCutBlock != nil) {
self.systemCutBlock();
}
}

- (void)selectAll:(id)sender {
if (self.selectAllBlock != nil) {
self.selectAllBlock();
} else if (self.systemSelectAllBlock != nil) {
self.systemSelectAllBlock();
}
}

Expand Down Expand Up @@ -439,4 +470,12 @@ - (UIMenu *)editMenuInteraction:(UIEditMenuInteraction *)interaction
return [UIMenu menuWithTitle:@"" children:allActions];
}

- (UIView *)inputView {
return nil;
}

- (UIView *)inputAccessoryView {
return nil;
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import platform.UIKit.UIKeyModifierFlags
import platform.UIKit.UIKeyModifierShift
import platform.UIKit.UIPress
import platform.UIKit.UIPressPhase.UIPressPhaseBegan
import platform.UIKit.UIPressPhase.UIPressPhaseCancelled
import platform.UIKit.UIPressPhase.UIPressPhaseEnded
import platform.UIKit.UIPressTypeDownArrow
import platform.UIKit.UIPressTypeLeftArrow
Expand All @@ -38,7 +39,7 @@ import platform.UIKit.UIPressTypeUpArrow
internal fun UIPress.toComposeEvent(): KeyEvent {
val keyEventType = when (phase) {
UIPressPhaseBegan -> KeyEventType.KeyDown
UIPressPhaseEnded -> KeyEventType.KeyUp
UIPressPhaseEnded, UIPressPhaseCancelled -> KeyEventType.KeyUp
else -> KeyEventType.Unknown
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,6 @@ import platform.UIKit.UITextWritingDirection

internal interface TextEditingDelegate {
var inputTraits: SkikoUITextInputTraits

/**
* Callback to handle keyboard presses. The parameter is a [Set] of [UIPress] objects.
* Erasure happens due to K/N not supporting Obj-C lightweight generics.
*/
var onKeyboardPresses: (Set<*>) -> Unit

fun onResignFocus()

Expand Down
Loading
Loading