1 /*
<lambda>null2  * Copyright 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.foundation.text.selection
18 
19 import androidx.compose.foundation.internal.hasText
20 import androidx.compose.foundation.internal.readAnnotatedString
21 import androidx.compose.foundation.internal.toClipEntry
22 import androidx.compose.foundation.text.DefaultCursorThickness
23 import androidx.compose.foundation.text.Handle
24 import androidx.compose.foundation.text.HandleState
25 import androidx.compose.foundation.text.HandleState.Cursor
26 import androidx.compose.foundation.text.HandleState.None
27 import androidx.compose.foundation.text.HandleState.Selection
28 import androidx.compose.foundation.text.LegacyTextFieldState
29 import androidx.compose.foundation.text.TextDragObserver
30 import androidx.compose.foundation.text.UndoManager
31 import androidx.compose.foundation.text.ValidatingEmptyOffsetMappingIdentity
32 import androidx.compose.foundation.text.detectDownAndDragGesturesWithObserver
33 import androidx.compose.foundation.text.getLineHeight
34 import androidx.compose.foundation.text.isPositionInsideSelection
35 import androidx.compose.runtime.Composable
36 import androidx.compose.runtime.getValue
37 import androidx.compose.runtime.mutableStateOf
38 import androidx.compose.runtime.remember
39 import androidx.compose.runtime.setValue
40 import androidx.compose.runtime.snapshots.Snapshot
41 import androidx.compose.ui.Modifier
42 import androidx.compose.ui.focus.FocusRequester
43 import androidx.compose.ui.geometry.Offset
44 import androidx.compose.ui.geometry.Rect
45 import androidx.compose.ui.hapticfeedback.HapticFeedback
46 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
47 import androidx.compose.ui.input.pointer.PointerEvent
48 import androidx.compose.ui.input.pointer.pointerInput
49 import androidx.compose.ui.platform.ClipEntry
50 import androidx.compose.ui.platform.Clipboard
51 import androidx.compose.ui.platform.TextToolbar
52 import androidx.compose.ui.platform.TextToolbarStatus
53 import androidx.compose.ui.text.AnnotatedString
54 import androidx.compose.ui.text.TextRange
55 import androidx.compose.ui.text.input.OffsetMapping
56 import androidx.compose.ui.text.input.PasswordVisualTransformation
57 import androidx.compose.ui.text.input.TextFieldValue
58 import androidx.compose.ui.text.input.VisualTransformation
59 import androidx.compose.ui.text.input.getSelectedText
60 import androidx.compose.ui.text.input.getTextAfterSelection
61 import androidx.compose.ui.text.input.getTextBeforeSelection
62 import androidx.compose.ui.text.style.ResolvedTextDirection
63 import androidx.compose.ui.unit.Density
64 import androidx.compose.ui.unit.IntSize
65 import androidx.compose.ui.unit.dp
66 import kotlin.math.absoluteValue
67 import kotlin.math.max
68 import kotlin.math.min
69 import kotlinx.coroutines.CoroutineScope
70 import kotlinx.coroutines.CoroutineStart
71 import kotlinx.coroutines.launch
72 
73 /** A bridge class between user interaction to the text field selection. */
74 internal class TextFieldSelectionManager(val undoManager: UndoManager? = null) {
75 
76     /** The current [OffsetMapping] for text field. */
77     internal var offsetMapping: OffsetMapping = ValidatingEmptyOffsetMappingIdentity
78 
79     /** Called when the input service updates the values in [TextFieldValue]. */
80     internal var onValueChange: (TextFieldValue) -> Unit = {}
81 
82     /** The current [LegacyTextFieldState]. */
83     internal var state: LegacyTextFieldState? = null
84 
85     /**
86      * The current [TextFieldValue]. This contains the original text, not the transformed text.
87      * Transformed text can be found with [transformedText].
88      */
89     internal var value: TextFieldValue by mutableStateOf(TextFieldValue())
90 
91     /**
92      * The current transformed text from the [LegacyTextFieldState]. The original text can be found
93      * in [value].
94      */
95     internal val transformedText
96         get() = state?.textDelegate?.text
97 
98     /**
99      * Visual transformation of the text field's text. Used to check if certain toolbar options are
100      * permitted. For example, 'cut' will not be available is it is password transformation.
101      */
102     internal var visualTransformation: VisualTransformation = VisualTransformation.None
103 
104     /** The action to invoke when autofill is requested in text toolbar. */
105     internal var requestAutofillAction: (() -> Unit)? = null
106 
107     /** [Clipboard] to perform clipboard features. */
108     internal var clipboard: Clipboard? = null
109 
110     /** [CoroutineScope] to perform clipboard features */
111     internal var coroutineScope: CoroutineScope? = null
112 
113     /** [TextToolbar] to show floating toolbar(post-M) or primary toolbar(pre-M). */
114     var textToolbar: TextToolbar? = null
115 
116     /** [HapticFeedback] handle to perform haptic feedback. */
117     var hapticFeedBack: HapticFeedback? = null
118 
119     /** [FocusRequester] used to request focus for the TextField. */
120     var focusRequester: FocusRequester? = null
121 
122     /** Defines if paste and cut toolbar menu actions should be shown */
123     var editable by mutableStateOf(true)
124 
125     /** Whether the text field should be selectable at all. */
126     var enabled by mutableStateOf(true)
127 
128     /**
129      * The beginning position of the drag gesture. Every time a new drag gesture starts, it wil be
130      * recalculated.
131      */
132     private var dragBeginPosition = Offset.Zero
133 
134     /**
135      * The beginning offset of the drag gesture translated into position in text. Every time a new
136      * drag gesture starts, it wil be recalculated. Unlike [dragBeginPosition] that is relative to
137      * the decoration box, [dragBeginOffsetInText] represents index in text. Essentially, it is
138      * equal to `layoutResult.getOffsetForPosition(dragBeginPosition)`.
139      */
140     private var dragBeginOffsetInText: Int? = null
141 
142     /**
143      * The total distance being dragged of the drag gesture. Every time a new drag gesture starts,
144      * it will be zeroed out.
145      */
146     private var dragTotalDistance = Offset.Zero
147 
148     /**
149      * A flag to check if a selection or cursor handle is being dragged, and which handle is being
150      * dragged. If this value is non-null, then onPress will not select any text. This value will be
151      * set to non-null when either handle is being dragged, and be reset to null when the dragging
152      * is stopped.
153      */
154     var draggingHandle: Handle? by mutableStateOf(null)
155         private set
156 
157     /** The current position of a drag, in decoration box coordinates. */
158     var currentDragPosition: Offset? by mutableStateOf(null)
159         private set
160 
161     /**
162      * The previous offset of a drag, before selection adjustments. Only update when a selection
163      * layout change has occurred, or set to -1 if a new drag begins.
164      */
165     private var previousRawDragOffset: Int = -1
166 
167     /**
168      * The old [TextFieldValue] before entering the selection mode on long press. Used to exit the
169      * selection mode.
170      */
171     private var oldValue: TextFieldValue = TextFieldValue()
172 
173     /** The previous [SelectionLayout] where [SelectionLayout.shouldRecomputeSelection] was true. */
174     private var previousSelectionLayout: SelectionLayout? = null
175 
176     // TODO(grantapher) android ClipboardManager has a way to notify primary clip changes.
177     //  That could possibly be used so that this doesn't have to be updated manually.
178     /** The current clip entry. Updated via [updateClipboardEntry]. */
179     private var clipEntry: ClipEntry? by mutableStateOf(null)
180 
181     /** [TextDragObserver] for long press and drag to select in TextField. */
182     internal val touchSelectionObserver =
183         object : TextDragObserver {
184             override fun onDown(point: Offset) {
185                 // Not supported for long-press-drag.
186             }
187 
188             override fun onUp() {
189                 // Nothing to do.
190             }
191 
192             override fun onStart(startPoint: Offset) {
193                 if (!enabled || draggingHandle != null) return
194                 // While selecting by long-press-dragging, the "end" of the selection is always the
195                 // one
196                 // being controlled by the drag.
197                 draggingHandle = Handle.SelectionEnd
198                 previousRawDragOffset = -1
199 
200                 // ensuring that current action mode (selection toolbar) is invalidated
201                 hideSelectionToolbar()
202 
203                 // Long Press at the blank area, the cursor should show up at the end of the line.
204                 if (state?.layoutResult?.isPositionOnText(startPoint) != true) {
205                     state?.layoutResult?.let { layoutResult ->
206                         val transformedOffset = layoutResult.getOffsetForPosition(startPoint)
207                         val offset = offsetMapping.transformedToOriginal(transformedOffset)
208 
209                         val newValue =
210                             createTextFieldValue(
211                                 annotatedString = value.annotatedString,
212                                 selection = TextRange(offset, offset)
213                             )
214 
215                         enterSelectionMode(showFloatingToolbar = false)
216                         hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
217                         onValueChange(newValue)
218                     }
219                 } else {
220                     if (value.text.isEmpty()) return
221                     enterSelectionMode(showFloatingToolbar = false)
222                     val adjustedStartSelection =
223                         updateSelection(
224                             // reset selection, otherwise a previous selection may be used
225                             // as context for creating the next selection
226                             value = value.copy(selection = TextRange.Zero),
227                             currentPosition = startPoint,
228                             isStartOfSelection = true,
229                             isStartHandle = false,
230                             adjustment = SelectionAdjustment.Word,
231                             isTouchBasedSelection = true,
232                         )
233                     // For touch, set the begin offset to the adjusted selection.
234                     // When char based selection is used, we want to ensure we snap the
235                     // beginning offset to the start word boundary of the first selected word.
236                     dragBeginOffsetInText = adjustedStartSelection.start
237                 }
238 
239                 // don't set selection handle state until drag ends
240                 setHandleState(None)
241 
242                 dragBeginPosition = startPoint
243                 currentDragPosition = dragBeginPosition
244                 dragTotalDistance = Offset.Zero
245             }
246 
247             override fun onDrag(delta: Offset) {
248                 // selection never started, did not consume any drag
249                 if (!enabled || value.text.isEmpty()) return
250 
251                 dragTotalDistance += delta
252                 state?.layoutResult?.let { layoutResult ->
253                     currentDragPosition = dragBeginPosition + dragTotalDistance
254 
255                     if (
256                         dragBeginOffsetInText == null &&
257                             !layoutResult.isPositionOnText(currentDragPosition!!)
258                     ) {
259                         // both start and end of drag is in end padding.
260                         val startOffset =
261                             offsetMapping.transformedToOriginal(
262                                 layoutResult.getOffsetForPosition(dragBeginPosition)
263                             )
264 
265                         val endOffset =
266                             offsetMapping.transformedToOriginal(
267                                 layoutResult.getOffsetForPosition(currentDragPosition!!)
268                             )
269 
270                         val adjustment =
271                             if (startOffset == endOffset) {
272                                 // start and end is in the same end padding, keep the collapsed
273                                 // selection
274                                 SelectionAdjustment.None
275                             } else {
276                                 SelectionAdjustment.Word
277                             }
278 
279                         updateSelection(
280                             value = value,
281                             currentPosition = currentDragPosition!!,
282                             isStartOfSelection = false,
283                             isStartHandle = false,
284                             adjustment = adjustment,
285                             isTouchBasedSelection = true,
286                         )
287                     } else {
288                         val startOffset =
289                             dragBeginOffsetInText
290                                 ?: layoutResult.getOffsetForPosition(
291                                     position = dragBeginPosition,
292                                     coerceInVisibleBounds = false
293                                 )
294                         val endOffset =
295                             layoutResult.getOffsetForPosition(
296                                 position = currentDragPosition!!,
297                                 coerceInVisibleBounds = false
298                             )
299 
300                         if (dragBeginOffsetInText == null && startOffset == endOffset) {
301                             // if we are selecting starting from end padding,
302                             // don't start selection until we have and un-collapsed selection.
303                             return
304                         }
305 
306                         updateSelection(
307                             value = value,
308                             currentPosition = currentDragPosition!!,
309                             isStartOfSelection = false,
310                             isStartHandle = false,
311                             adjustment = SelectionAdjustment.Word,
312                             isTouchBasedSelection = true,
313                         )
314                     }
315                 }
316                 updateFloatingToolbar(show = false)
317             }
318 
319             override fun onStop() = onEnd()
320 
321             override fun onCancel() = onEnd()
322 
323             private fun onEnd() {
324                 draggingHandle = null
325                 currentDragPosition = null
326                 updateFloatingToolbar(show = true)
327                 dragBeginOffsetInText = null
328 
329                 val collapsed = value.selection.collapsed
330                 setHandleState(if (collapsed) Cursor else Selection)
331                 state?.showSelectionHandleStart =
332                     !collapsed && isSelectionHandleInVisibleBound(isStartHandle = true)
333                 state?.showSelectionHandleEnd =
334                     !collapsed && isSelectionHandleInVisibleBound(isStartHandle = false)
335                 state?.showCursorHandle =
336                     collapsed && isSelectionHandleInVisibleBound(isStartHandle = true)
337             }
338         }
339 
340     internal val mouseSelectionObserver =
341         object : MouseSelectionObserver {
342             override fun onExtend(downPosition: Offset): Boolean {
343                 // can't update selection without a layoutResult, so don't consume
344                 state?.layoutResult ?: return false
345                 if (!enabled) return false
346                 previousRawDragOffset = -1
347                 updateMouseSelection(
348                     value = value,
349                     currentPosition = downPosition,
350                     isStartOfSelection = false,
351                     adjustment = SelectionAdjustment.None,
352                 )
353                 return true
354             }
355 
356             override fun onExtendDrag(dragPosition: Offset): Boolean {
357                 if (!enabled || value.text.isEmpty()) return false
358                 // can't update selection without a layoutResult, so don't consume
359                 state?.layoutResult ?: return false
360 
361                 updateMouseSelection(
362                     value = value,
363                     currentPosition = dragPosition,
364                     isStartOfSelection = false,
365                     adjustment = SelectionAdjustment.None,
366                 )
367                 return true
368             }
369 
370             override fun onStart(downPosition: Offset, adjustment: SelectionAdjustment): Boolean {
371                 if (!enabled || value.text.isEmpty()) return false
372                 // can't update selection without a layoutResult, so don't consume
373                 state?.layoutResult ?: return false
374 
375                 focusRequester?.requestFocus()
376                 dragBeginPosition = downPosition
377                 previousRawDragOffset = -1
378                 enterSelectionMode()
379                 updateMouseSelection(
380                     value = value,
381                     currentPosition = dragBeginPosition,
382                     isStartOfSelection = true,
383                     adjustment = adjustment,
384                 )
385                 return true
386             }
387 
388             override fun onDrag(dragPosition: Offset, adjustment: SelectionAdjustment): Boolean {
389                 if (!enabled || value.text.isEmpty()) return false
390                 // can't update selection without a layoutResult, so don't consume
391                 state?.layoutResult ?: return false
392 
393                 updateMouseSelection(
394                     value = value,
395                     currentPosition = dragPosition,
396                     isStartOfSelection = false,
397                     adjustment = adjustment,
398                 )
399                 return true
400             }
401 
402             fun updateMouseSelection(
403                 value: TextFieldValue,
404                 currentPosition: Offset,
405                 isStartOfSelection: Boolean,
406                 adjustment: SelectionAdjustment,
407             ) {
408                 val newSelection =
409                     updateSelection(
410                         value = value,
411                         currentPosition = currentPosition,
412                         isStartOfSelection = isStartOfSelection,
413                         isStartHandle = false,
414                         adjustment = adjustment,
415                         isTouchBasedSelection = false,
416                     )
417                 setHandleState(if (newSelection.collapsed) Cursor else Selection)
418             }
419 
420             override fun onDragDone() {
421                 /* Nothing to do */
422             }
423         }
424 
425     /**
426      * [TextDragObserver] for dragging the selection handles to change the selection in TextField.
427      */
428     internal fun handleDragObserver(isStartHandle: Boolean): TextDragObserver =
429         object : TextDragObserver {
430             override fun onDown(point: Offset) {
431                 draggingHandle = if (isStartHandle) Handle.SelectionStart else Handle.SelectionEnd
432 
433                 // The position of the character where the drag gesture should begin. This is in
434                 // the inner text field coordinates.
435                 val handleCoordinates = getAdjustedCoordinates(getHandlePosition(isStartHandle))
436 
437                 // translate to decoration box coordinates
438                 val layoutResult = state?.layoutResult ?: return
439                 val translatedPosition =
440                     layoutResult.translateInnerToDecorationCoordinates(handleCoordinates)
441 
442                 dragBeginPosition = translatedPosition
443                 currentDragPosition = translatedPosition
444 
445                 // Zero out the total distance that being dragged.
446                 dragTotalDistance = Offset.Zero
447                 previousRawDragOffset = -1
448 
449                 state?.isInTouchMode = true
450                 updateFloatingToolbar(show = false)
451             }
452 
453             override fun onUp() {
454                 draggingHandle = null
455                 currentDragPosition = null
456                 updateFloatingToolbar(show = true)
457             }
458 
459             override fun onStart(startPoint: Offset) {
460                 // handled in onDown
461             }
462 
463             override fun onDrag(delta: Offset) {
464                 dragTotalDistance += delta
465 
466                 currentDragPosition = dragBeginPosition + dragTotalDistance
467                 updateSelection(
468                     value = value,
469                     currentPosition = currentDragPosition!!,
470                     isStartOfSelection = false,
471                     isStartHandle = isStartHandle,
472                     adjustment = SelectionAdjustment.CharacterWithWordAccelerate,
473                     isTouchBasedSelection = true, // handle drag infers touch
474                 )
475                 updateFloatingToolbar(show = false)
476             }
477 
478             override fun onStop() {
479                 draggingHandle = null
480                 currentDragPosition = null
481                 updateFloatingToolbar(show = true)
482             }
483 
484             override fun onCancel() {}
485         }
486 
487     /** [TextDragObserver] for dragging the cursor to change the selection in TextField. */
488     internal fun cursorDragObserver(): TextDragObserver =
489         object : TextDragObserver {
490             override fun onDown(point: Offset) {
491                 // Nothing
492             }
493 
494             override fun onUp() {
495                 draggingHandle = null
496                 currentDragPosition = null
497             }
498 
499             override fun onStart(startPoint: Offset) {
500                 // The position of the character where the drag gesture should begin. This is in
501                 // the inner text field coordinates.
502                 val handleCoordinates = getAdjustedCoordinates(getHandlePosition(true))
503 
504                 // translate to decoration box coordinates
505                 val layoutResult = state?.layoutResult ?: return
506                 val translatedPosition =
507                     layoutResult.translateInnerToDecorationCoordinates(handleCoordinates)
508 
509                 dragBeginPosition = translatedPosition
510                 currentDragPosition = translatedPosition
511                 // Zero out the total distance that being dragged.
512                 dragTotalDistance = Offset.Zero
513                 draggingHandle = Handle.Cursor
514                 updateFloatingToolbar(show = false)
515             }
516 
517             override fun onDrag(delta: Offset) {
518                 dragTotalDistance += delta
519 
520                 state?.layoutResult?.let { layoutResult ->
521                     currentDragPosition = dragBeginPosition + dragTotalDistance
522                     val offset =
523                         offsetMapping.transformedToOriginal(
524                             layoutResult.getOffsetForPosition(currentDragPosition!!)
525                         )
526 
527                     val newSelection = TextRange(offset, offset)
528 
529                     // Nothing changed, skip onValueChange hand hapticFeedback.
530                     if (newSelection == value.selection) return
531 
532                     if (state?.isInTouchMode != false) {
533                         hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
534                     }
535 
536                     onValueChange(
537                         createTextFieldValue(
538                             annotatedString = value.annotatedString,
539                             selection = newSelection
540                         )
541                     )
542                 }
543             }
544 
545             override fun onStop() {
546                 draggingHandle = null
547                 currentDragPosition = null
548             }
549 
550             override fun onCancel() {}
551         }
552 
553     /**
554      * The method to record the required state values on entering the selection mode.
555      *
556      * Is triggered on long press or accessibility action.
557      *
558      * @param showFloatingToolbar whether to show the floating toolbar when entering selection mode
559      */
560     internal fun enterSelectionMode(showFloatingToolbar: Boolean = true) {
561         if (state?.hasFocus == false) {
562             focusRequester?.requestFocus()
563         }
564         oldValue = value
565         updateFloatingToolbar(showFloatingToolbar)
566         setHandleState(Selection)
567     }
568 
569     /**
570      * The method to record the corresponding state values on exiting the selection mode.
571      *
572      * Is triggered on accessibility action.
573      */
574     internal fun exitSelectionMode() {
575         updateFloatingToolbar(show = false)
576         setHandleState(None)
577     }
578 
579     internal fun deselect(position: Offset? = null) {
580         if (!value.selection.collapsed) {
581             // if selection was not collapsed, set a default cursor location, otherwise
582             // don't change the location of the cursor.
583             val layoutResult = state?.layoutResult
584             val newCursorOffset =
585                 if (position != null && layoutResult != null) {
586                     offsetMapping.transformedToOriginal(layoutResult.getOffsetForPosition(position))
587                 } else {
588                     value.selection.max
589                 }
590             val newValue = value.copy(selection = TextRange(newCursorOffset))
591             onValueChange(newValue)
592         }
593 
594         // If a new cursor position is given and the text is not empty, enter the Cursor state.
595         val selectionMode = if (position != null && value.text.isNotEmpty()) Cursor else None
596         setHandleState(selectionMode)
597         updateFloatingToolbar(show = false)
598     }
599 
600     internal fun setSelectionPreviewHighlight(range: TextRange) {
601         state?.selectionPreviewHighlightRange = range
602         state?.deletionPreviewHighlightRange = TextRange.Zero
603         if (!range.collapsed) exitSelectionMode()
604     }
605 
606     internal fun setDeletionPreviewHighlight(range: TextRange) {
607         state?.deletionPreviewHighlightRange = range
608         state?.selectionPreviewHighlightRange = TextRange.Zero
609         if (!range.collapsed) exitSelectionMode()
610     }
611 
612     internal fun clearPreviewHighlight() {
613         state?.deletionPreviewHighlightRange = TextRange.Zero
614         state?.selectionPreviewHighlightRange = TextRange.Zero
615     }
616 
617     internal val textToolbarShown
618         get() = textToolbar?.status == TextToolbarStatus.Shown
619 
620     private val isPassword: Boolean
621         get() = visualTransformation is PasswordVisualTransformation
622 
623     private val hasSelection: Boolean
624         get() = !value.selection.collapsed
625 
626     internal fun canCopy(): Boolean = hasSelection && !isPassword
627 
628     internal suspend fun updateClipboardEntry() {
629         clipEntry = clipboard?.getClipEntry()
630     }
631 
632     /** Only fully accurate if [updateClipboardEntry] has been called. */
633     internal fun canPaste(): Boolean = editable && clipEntry?.hasText() == true
634 
635     internal fun canCut(): Boolean = hasSelection && editable && !isPassword
636 
637     internal fun canSelectAll(): Boolean = value.selection.length != value.text.length
638 
639     internal fun canAutofill(): Boolean = editable && value.selection.collapsed
640 
641     /**
642      * The method for copying text.
643      *
644      * If there is no selection, return. Put the selected text into the [Clipboard], and cancel the
645      * selection, if [cancelSelection] is true. The text in the text field should be unchanged. If
646      * [cancelSelection] is true, the new cursor offset should be at the end of the previous
647      * selected text.
648      */
649     internal fun copy(cancelSelection: Boolean = true) =
650         coroutineScope?.launch(start = CoroutineStart.UNDISPATCHED) {
651             if (value.selection.collapsed) return@launch
652 
653             // TODO(b/171947959) check if original or transformed should be copied
654             clipboard?.setClipEntry(value.getSelectedText().toClipEntry())
655 
656             if (!cancelSelection) return@launch
657 
658             val newCursorOffset = value.selection.max
659             val newValue =
660                 createTextFieldValue(
661                     annotatedString = value.annotatedString,
662                     selection = TextRange(newCursorOffset, newCursorOffset)
663                 )
664             onValueChange(newValue)
665             setHandleState(None)
666         }
667 
668     /**
669      * The method for pasting text.
670      *
671      * Get the text from [Clipboard]. If it's null, return. The new text should be the text before
672      * the selected text, plus the text from the [Clipboard], and plus the text after the selected
673      * text. Then the selection should collapse, and the new cursor offset should be the end of the
674      * newly added text.
675      */
676     internal fun paste() =
677         coroutineScope?.launch(start = CoroutineStart.UNDISPATCHED) {
678             val text = clipboard?.getClipEntry()?.readAnnotatedString() ?: return@launch
679 
680             val newText =
681                 value.getTextBeforeSelection(value.text.length) +
682                     text +
683                     value.getTextAfterSelection(value.text.length)
684             val newCursorOffset = value.selection.min + text.length
685 
686             val newValue =
687                 createTextFieldValue(
688                     annotatedString = newText,
689                     selection = TextRange(newCursorOffset, newCursorOffset)
690                 )
691             onValueChange(newValue)
692             setHandleState(None)
693             undoManager?.forceNextSnapshot()
694         }
695 
696     /**
697      * The method for cutting text.
698      *
699      * If there is no selection, return. Put the selected text into the [Clipboard]. The new text
700      * should be the text before the selection plus the text after the selection. And the new cursor
701      * offset should be between the text before the selection, and the text after the selection.
702      */
703     internal fun cut() =
704         coroutineScope?.launch(start = CoroutineStart.UNDISPATCHED) {
705             if (value.selection.collapsed) return@launch
706 
707             // TODO(b/171947959) check if original or transformed should be cut
708             clipboard?.setClipEntry(value.getSelectedText().toClipEntry())
709 
710             val newText =
711                 value.getTextBeforeSelection(value.text.length) +
712                     value.getTextAfterSelection(value.text.length)
713             val newCursorOffset = value.selection.min
714 
715             val newValue =
716                 createTextFieldValue(
717                     annotatedString = newText,
718                     selection = TextRange(newCursorOffset, newCursorOffset)
719                 )
720             onValueChange(newValue)
721             setHandleState(None)
722             undoManager?.forceNextSnapshot()
723         }
724 
725     /*@VisibleForTesting*/
726     internal fun selectAll() {
727         val newValue =
728             createTextFieldValue(
729                 annotatedString = value.annotatedString,
730                 selection = TextRange(0, value.text.length)
731             )
732         onValueChange(newValue)
733         oldValue = oldValue.copy(selection = newValue.selection)
734         enterSelectionMode(showFloatingToolbar = true)
735     }
736 
737     internal fun autofill() {
738         requestAutofillAction?.invoke()
739     }
740 
741     internal fun getHandlePosition(isStartHandle: Boolean): Offset {
742         val textLayoutResult = state?.layoutResult?.value ?: return Offset.Unspecified
743 
744         // If layout and value are out of sync, return unspecified.
745         // This will be called again once they are in sync.
746         val transformedText = transformedText ?: return Offset.Unspecified
747         val layoutInputText = textLayoutResult.layoutInput.text.text
748         if (transformedText.text != layoutInputText) return Offset.Unspecified
749 
750         val offset = if (isStartHandle) value.selection.start else value.selection.end
751         return getSelectionHandleCoordinates(
752             textLayoutResult = textLayoutResult,
753             offset = offsetMapping.originalToTransformed(offset),
754             isStart = isStartHandle,
755             areHandlesCrossed = value.selection.reversed
756         )
757     }
758 
759     internal fun getHandleLineHeight(isStartHandle: Boolean): Float {
760         val offset = if (isStartHandle) value.selection.start else value.selection.end
761         return state?.layoutResult?.value?.getLineHeight(offset) ?: return 0f
762     }
763 
764     internal fun getCursorPosition(density: Density): Offset {
765         val offset = offsetMapping.originalToTransformed(value.selection.start)
766         val layoutResult = state?.layoutResult!!.value
767         val cursorRect =
768             layoutResult.getCursorRect(offset.coerceIn(0, layoutResult.layoutInput.text.length))
769         val x = with(density) { cursorRect.left + DefaultCursorThickness.toPx() / 2 }
770         return Offset(x, cursorRect.bottom)
771     }
772 
773     /**
774      * Update the [LegacyTextFieldState.showFloatingToolbar] state and show/hide the toolbar.
775      *
776      * You may want to call [showSelectionToolbar] and [hideSelectionToolbar] directly without
777      * updating the [LegacyTextFieldState.showFloatingToolbar] if you are simply hiding all touch
778      * selection behaviors (toolbar, handles, cursor, magnifier), but want the toolbar to come back
779      * when you un-hide all those behaviors.
780      */
781     private fun updateFloatingToolbar(show: Boolean) {
782         state?.showFloatingToolbar = show
783         if (show) showSelectionToolbar() else hideSelectionToolbar()
784     }
785 
786     /**
787      * This function get the selected region as a Rectangle region, and pass it to [TextToolbar] to
788      * make the FloatingToolbar show up in the proper place. In addition, this function passes the
789      * copy, paste and cut method as callbacks when "copy", "cut" or "paste" is clicked.
790      */
791     internal fun showSelectionToolbar() =
792         coroutineScope?.launch(start = CoroutineStart.UNDISPATCHED) {
793             // Because this is undispatched and the above is called once in CoreTextField
794             // composition, disable read observation to avoid reading many states and landing
795             // in a composition loop.
796             Snapshot.withoutReadObservation {
797                 if (!enabled || state?.isInTouchMode == false) return@launch
798                 val copy: (() -> Unit)? =
799                     if (canCopy()) {
800                         {
801                             coroutineScope?.launch(start = CoroutineStart.UNDISPATCHED) { copy() }
802                             hideSelectionToolbar()
803                         }
804                     } else null
805 
806                 val cut: (() -> Unit)? =
807                     if (canCut()) {
808                         {
809                             coroutineScope?.launch(start = CoroutineStart.UNDISPATCHED) { cut() }
810                             hideSelectionToolbar()
811                         }
812                     } else null
813 
814                 updateClipboardEntry()
815                 val paste: (() -> Unit)? =
816                     if (canPaste()) {
817                         {
818                             coroutineScope?.launch(start = CoroutineStart.UNDISPATCHED) { paste() }
819                             hideSelectionToolbar()
820                         }
821                     } else null
822 
823                 val selectAll: (() -> Unit)? =
824                     if (canSelectAll()) {
825                         { selectAll() }
826                     } else null
827 
828                 val autofill: (() -> Unit)? =
829                     if (canAutofill()) {
830                         { autofill() }
831                     } else null
832 
833                 textToolbar?.showMenu(
834                     rect = getContentRect(),
835                     onCopyRequested = copy,
836                     onPasteRequested = paste,
837                     onCutRequested = cut,
838                     onSelectAllRequested = selectAll,
839                     onAutofillRequested = autofill
840                 )
841             }
842         }
843 
844     internal fun hideSelectionToolbar() {
845         if (textToolbar?.status == TextToolbarStatus.Shown) {
846             textToolbar?.hide()
847         }
848     }
849 
850     /**
851      * Implements the macOS select-word-on-right-click behavior.
852      *
853      * If the current selection does not already include [position], select the word at [position].
854      */
855     fun selectWordAtPositionIfNotAlreadySelected(position: Offset) {
856         val layoutResult = state?.layoutResult ?: return
857         val isClickedPositionInsideSelection =
858             layoutResult.value.isPositionInsideSelection(
859                 position = layoutResult.translateDecorationToInnerCoordinates(position),
860                 selectionRange = value.selection,
861             )
862         if (!isClickedPositionInsideSelection) {
863             updateSelection(
864                 value = value,
865                 currentPosition = position,
866                 isStartOfSelection = true,
867                 isStartHandle = false,
868                 adjustment = SelectionAdjustment.Word,
869                 isTouchBasedSelection = false,
870             )
871         }
872     }
873 
874     /**
875      * Check if the text in the text field changed. When the content in the text field is modified,
876      * this method returns true.
877      */
878     internal fun isTextChanged(): Boolean {
879         return oldValue.text != value.text
880     }
881 
882     /**
883      * Calculate selected region as [Rect]. The top is the top of the first selected line, and the
884      * bottom is the bottom of the last selected line. The left is the leftmost handle's horizontal
885      * coordinates, and the right is the rightmost handle's coordinates.
886      */
887     private fun getContentRect(): Rect {
888         // if it's stale layout, return empty Rect
889         state
890             ?.takeIf { !it.isLayoutResultStale }
891             ?.let {
892                 // value.selection is from the original representation.
893                 // we need to convert original offsets into transformed offsets to query
894                 // layoutResult because layoutResult belongs to the transformed text.
895                 val transformedStart = offsetMapping.originalToTransformed(value.selection.start)
896                 val transformedEnd = offsetMapping.originalToTransformed(value.selection.end)
897                 val startOffset =
898                     state?.layoutCoordinates?.localToRoot(getHandlePosition(true)) ?: Offset.Zero
899                 val endOffset =
900                     state?.layoutCoordinates?.localToRoot(getHandlePosition(false)) ?: Offset.Zero
901                 val startTop =
902                     state
903                         ?.layoutCoordinates
904                         ?.localToRoot(
905                             Offset(
906                                 0f,
907                                 it.layoutResult?.value?.getCursorRect(transformedStart)?.top ?: 0f
908                             )
909                         )
910                         ?.y ?: 0f
911                 val endTop =
912                     state
913                         ?.layoutCoordinates
914                         ?.localToRoot(
915                             Offset(
916                                 x = 0f,
917                                 y = it.layoutResult?.value?.getCursorRect(transformedEnd)?.top ?: 0f
918                             )
919                         )
920                         ?.y ?: 0f
921 
922                 val left = min(startOffset.x, endOffset.x)
923                 val right = max(startOffset.x, endOffset.x)
924                 val top = min(startTop, endTop)
925                 val bottom =
926                     max(startOffset.y, endOffset.y) + 25.dp.value * it.textDelegate.density.density
927 
928                 return Rect(left, top, right, bottom)
929             }
930 
931         return Rect.Zero
932     }
933 
934     /**
935      * Update the text field's selection based on new offsets.
936      *
937      * @param value the current [TextFieldValue]
938      * @param currentPosition the current position of the cursor/drag in the decoration box
939      *   coordinates
940      * @param isStartOfSelection whether this is the first updateSelection of a selection gesture.
941      *   If true, will ignore any previous selection context.
942      * @param isStartHandle whether the start handle is being updated
943      * @param adjustment The selection adjustment to use
944      * @param isTouchBasedSelection Whether this is a touch based selection
945      */
946     private fun updateSelection(
947         value: TextFieldValue,
948         currentPosition: Offset,
949         isStartOfSelection: Boolean,
950         isStartHandle: Boolean,
951         adjustment: SelectionAdjustment,
952         isTouchBasedSelection: Boolean,
953     ): TextRange {
954         val layoutResult = state?.layoutResult ?: return TextRange.Zero
955         val previousTransformedSelection =
956             TextRange(
957                 offsetMapping.originalToTransformed(value.selection.start),
958                 offsetMapping.originalToTransformed(value.selection.end)
959             )
960 
961         val currentOffset =
962             layoutResult.getOffsetForPosition(
963                 position = currentPosition,
964                 coerceInVisibleBounds = false
965             )
966 
967         val rawStartHandleOffset =
968             if (isStartHandle || isStartOfSelection) currentOffset
969             else previousTransformedSelection.start
970 
971         val rawEndHandleOffset =
972             if (!isStartHandle || isStartOfSelection) currentOffset
973             else previousTransformedSelection.end
974 
975         val previousSelectionLayout = previousSelectionLayout // for smart cast
976         val rawPreviousHandleOffset =
977             if (
978                 isStartOfSelection || previousSelectionLayout == null || previousRawDragOffset == -1
979             ) {
980                 -1
981             } else {
982                 previousRawDragOffset
983             }
984 
985         val selectionLayout =
986             getTextFieldSelectionLayout(
987                 layoutResult = layoutResult.value,
988                 rawStartHandleOffset = rawStartHandleOffset,
989                 rawEndHandleOffset = rawEndHandleOffset,
990                 rawPreviousHandleOffset = rawPreviousHandleOffset,
991                 previousSelectionRange = previousTransformedSelection,
992                 isStartOfSelection = isStartOfSelection,
993                 isStartHandle = isStartHandle,
994             )
995 
996         if (!selectionLayout.shouldRecomputeSelection(previousSelectionLayout)) {
997             return value.selection
998         }
999 
1000         this.previousSelectionLayout = selectionLayout
1001         previousRawDragOffset = currentOffset
1002 
1003         val newTransformedSelection = adjustment.adjust(selectionLayout)
1004         val newSelection =
1005             TextRange(
1006                 start = offsetMapping.transformedToOriginal(newTransformedSelection.start.offset),
1007                 end = offsetMapping.transformedToOriginal(newTransformedSelection.end.offset)
1008             )
1009 
1010         if (newSelection == value.selection) return value.selection
1011 
1012         val onlyChangeIsReversed =
1013             newSelection.reversed != value.selection.reversed &&
1014                 with(newSelection) { TextRange(end, start) } == value.selection
1015 
1016         val bothSelectionsCollapsed = newSelection.collapsed && value.selection.collapsed
1017         if (
1018             isTouchBasedSelection &&
1019                 value.text.isNotEmpty() &&
1020                 !onlyChangeIsReversed &&
1021                 !bothSelectionsCollapsed
1022         ) {
1023             hapticFeedBack?.performHapticFeedback(HapticFeedbackType.TextHandleMove)
1024         }
1025 
1026         val newValue =
1027             createTextFieldValue(annotatedString = value.annotatedString, selection = newSelection)
1028         onValueChange(newValue)
1029 
1030         if (!isTouchBasedSelection) {
1031             updateFloatingToolbar(show = !newSelection.collapsed)
1032         }
1033 
1034         state?.isInTouchMode = isTouchBasedSelection
1035 
1036         // showSelectionHandleStart/End might be set to false when scrolled out of the view.
1037         // When the selection is updated, they must also be updated so that handles will be shown
1038         // or hidden correctly.
1039         state?.showSelectionHandleStart =
1040             !newSelection.collapsed && isSelectionHandleInVisibleBound(isStartHandle = true)
1041         state?.showSelectionHandleEnd =
1042             !newSelection.collapsed && isSelectionHandleInVisibleBound(isStartHandle = false)
1043         state?.showCursorHandle =
1044             newSelection.collapsed && isSelectionHandleInVisibleBound(isStartHandle = true)
1045 
1046         return newSelection
1047     }
1048 
1049     private fun setHandleState(handleState: HandleState) {
1050         state?.takeUnless { it.handleState == handleState }?.let { it.handleState = handleState }
1051     }
1052 
1053     private fun createTextFieldValue(
1054         annotatedString: AnnotatedString,
1055         selection: TextRange
1056     ): TextFieldValue {
1057         return TextFieldValue(annotatedString = annotatedString, selection = selection)
1058     }
1059 }
1060 
1061 @Composable
TextFieldSelectionHandlenull1062 internal fun TextFieldSelectionHandle(
1063     isStartHandle: Boolean,
1064     direction: ResolvedTextDirection,
1065     manager: TextFieldSelectionManager
1066 ) {
1067     val observer = remember(isStartHandle, manager) { manager.handleDragObserver(isStartHandle) }
1068 
1069     SelectionHandle(
1070         offsetProvider = { manager.getHandlePosition(isStartHandle) },
1071         isStartHandle = isStartHandle,
1072         direction = direction,
1073         handlesCrossed = manager.value.selection.reversed,
1074         lineHeight = manager.getHandleLineHeight(isStartHandle),
1075         modifier =
1076             Modifier.pointerInput(observer) { detectDownAndDragGesturesWithObserver(observer) },
1077     )
1078 }
1079 
1080 /** Whether the selection handle is in the visible bound of the TextField. */
isSelectionHandleInVisibleBoundnull1081 internal fun TextFieldSelectionManager.isSelectionHandleInVisibleBound(
1082     isStartHandle: Boolean
1083 ): Boolean =
1084     state?.layoutCoordinates?.visibleBounds()?.containsInclusive(getHandlePosition(isStartHandle))
1085         ?: false
1086 
1087 // TODO(b/180075467) it should be part of PointerEvent API in one way or another
1088 internal expect val PointerEvent.isShiftPressed: Boolean
1089 
1090 /**
1091  * Optionally shows a magnifier widget, if the current platform supports it, for the current state
1092  * of a [TextFieldSelectionManager]. Should check [TextFieldSelectionManager.draggingHandle] to see
1093  * which handle is being dragged and then calculate the magnifier position for that handle.
1094  *
1095  * Actual implementations should as much as possible actually live in this common source set, _not_
1096  * the platform-specific source sets. The actual implementations of this function should then just
1097  * delegate to those functions.
1098  */
1099 internal expect fun Modifier.textFieldMagnifier(manager: TextFieldSelectionManager): Modifier
1100 
1101 /** @return the location of the magnifier relative to the inner text field coordinates */
1102 internal fun calculateSelectionMagnifierCenterAndroid(
1103     manager: TextFieldSelectionManager,
1104     magnifierSize: IntSize
1105 ): Offset {
1106     // state read of currentDragPosition so that we always recompose on drag position changes
1107     val localDragPosition = manager.currentDragPosition ?: return Offset.Unspecified
1108 
1109     // Never show the magnifier in an empty text field.
1110     if (manager.transformedText?.isEmpty() != false) return Offset.Unspecified
1111     val rawTextOffset =
1112         when (manager.draggingHandle) {
1113             null -> return Offset.Unspecified
1114             Handle.Cursor,
1115             Handle.SelectionStart -> manager.value.selection.start
1116             Handle.SelectionEnd -> manager.value.selection.end
1117         }
1118     // If the text hasn't been laid out yet, don't show the magnifier.
1119     val textLayoutResultProxy = manager.state?.layoutResult ?: return Offset.Unspecified
1120     val transformedText = manager.state?.textDelegate?.text ?: return Offset.Unspecified
1121 
1122     val textOffset =
1123         manager.offsetMapping
1124             .originalToTransformed(rawTextOffset)
1125             .coerceIn(0, transformedText.length)
1126 
1127     val dragX = textLayoutResultProxy.translateDecorationToInnerCoordinates(localDragPosition).x
1128 
1129     val layoutResult = textLayoutResultProxy.value
1130     val line = layoutResult.getLineForOffset(textOffset)
1131     val lineStart = layoutResult.getLineLeft(line)
1132     val lineEnd = layoutResult.getLineRight(line)
1133     val lineMin = minOf(lineStart, lineEnd)
1134     val lineMax = maxOf(lineStart, lineEnd)
1135     val centerX = dragX.coerceIn(lineMin, lineMax)
1136 
1137     // Hide the magnifier when dragged too far (outside the horizontal bounds of how big the
1138     // magnifier actually is). See
1139     // https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/Editor.java;l=5228-5231;drc=2fdb6bd709be078b72f011334362456bb758922c
1140     // Also check whether magnifierSize is calculated. A platform magnifier instance is not
1141     // created until it's requested for the first time. So the size will only be calculated after we
1142     // return a specified offset from this function.
1143     // It is very unlikely that this behavior would cause a flicker since magnifier immediately
1144     // shows up where the pointer is being dragged. The pointer needs to drag further than the half
1145     // of magnifier's width to hide by the following logic.
1146     if (
1147         magnifierSize != IntSize.Zero && (dragX - centerX).absoluteValue > magnifierSize.width / 2
1148     ) {
1149         return Offset.Unspecified
1150     }
1151 
1152     // Center vertically on the current line.
1153     val top = layoutResult.getLineTop(line)
1154     val bottom = layoutResult.getLineBottom(line)
1155     val centerY = ((bottom - top) / 2) + top
1156 
1157     return Offset(centerX, centerY)
1158 }
1159 
addBasicTextFieldTextContextMenuComponentsnull1160 internal expect fun Modifier.addBasicTextFieldTextContextMenuComponents(
1161     manager: TextFieldSelectionManager,
1162     coroutineScope: CoroutineScope,
1163 ): Modifier
1164