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