1 /*
2 * Copyright 2024 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.input.internal
18
19 import android.graphics.PointF
20 import android.os.CancellationSignal
21 import android.view.inputmethod.DeleteGesture
22 import android.view.inputmethod.DeleteRangeGesture
23 import android.view.inputmethod.HandwritingGesture
24 import android.view.inputmethod.InputConnection
25 import android.view.inputmethod.InsertGesture
26 import android.view.inputmethod.JoinOrSplitGesture
27 import android.view.inputmethod.PreviewableHandwritingGesture
28 import android.view.inputmethod.RemoveSpaceGesture
29 import android.view.inputmethod.SelectGesture
30 import android.view.inputmethod.SelectRangeGesture
31 import androidx.annotation.RequiresApi
32 import androidx.compose.foundation.text.LegacyTextFieldState
33 import androidx.compose.foundation.text.input.TextHighlightType
34 import androidx.compose.foundation.text.selection.TextFieldSelectionManager
35 import androidx.compose.ui.geometry.Offset
36 import androidx.compose.ui.geometry.Rect
37 import androidx.compose.ui.graphics.toComposeRect
38 import androidx.compose.ui.layout.LayoutCoordinates
39 import androidx.compose.ui.platform.ViewConfiguration
40 import androidx.compose.ui.text.AnnotatedString
41 import androidx.compose.ui.text.MultiParagraph
42 import androidx.compose.ui.text.TextGranularity
43 import androidx.compose.ui.text.TextInclusionStrategy
44 import androidx.compose.ui.text.TextLayoutResult
45 import androidx.compose.ui.text.TextRange
46 import androidx.compose.ui.text.input.CommitTextCommand
47 import androidx.compose.ui.text.input.DeleteSurroundingTextCommand
48 import androidx.compose.ui.text.input.EditCommand
49 import androidx.compose.ui.text.input.EditingBuffer
50 import androidx.compose.ui.text.input.SetSelectionCommand
51 import androidx.compose.ui.text.substring
52 import kotlin.math.max
53 import kotlin.math.min
54
55 @RequiresApi(34)
56 internal object HandwritingGestureApi34 {
performHandwritingGesturenull57 internal fun TransformedTextFieldState.performHandwritingGesture(
58 handwritingGesture: HandwritingGesture,
59 layoutState: TextLayoutState,
60 updateSelectionState: (() -> Unit)?,
61 viewConfiguration: ViewConfiguration?
62 ): Int {
63 return when (handwritingGesture) {
64 is SelectGesture ->
65 performSelectGesture(handwritingGesture, layoutState, updateSelectionState)
66 is DeleteGesture -> performDeleteGesture(handwritingGesture, layoutState)
67 is SelectRangeGesture ->
68 performSelectRangeGesture(handwritingGesture, layoutState, updateSelectionState)
69 is DeleteRangeGesture -> performDeleteRangeGesture(handwritingGesture, layoutState)
70 is JoinOrSplitGesture ->
71 performJoinOrSplitGesture(handwritingGesture, layoutState, viewConfiguration)
72 is InsertGesture ->
73 performInsertGesture(handwritingGesture, layoutState, viewConfiguration)
74 is RemoveSpaceGesture ->
75 performRemoveSpaceGesture(handwritingGesture, layoutState, viewConfiguration)
76 else -> InputConnection.HANDWRITING_GESTURE_RESULT_UNSUPPORTED
77 }
78 }
79
previewHandwritingGesturenull80 internal fun TransformedTextFieldState.previewHandwritingGesture(
81 handwritingGesture: PreviewableHandwritingGesture,
82 layoutState: TextLayoutState,
83 cancellationSignal: CancellationSignal?
84 ): Boolean {
85 when (handwritingGesture) {
86 is SelectGesture -> previewSelectGesture(handwritingGesture, layoutState)
87 is DeleteGesture -> previewDeleteGesture(handwritingGesture, layoutState)
88 is SelectRangeGesture -> previewSelectRangeGesture(handwritingGesture, layoutState)
89 is DeleteRangeGesture -> previewDeleteRangeGesture(handwritingGesture, layoutState)
90 else -> return false
91 }
92 cancellationSignal?.setOnCancelListener { editUntransformedTextAsUser { clearHighlight() } }
93 return true
94 }
95
TransformedTextFieldStatenull96 private fun TransformedTextFieldState.performSelectGesture(
97 gesture: SelectGesture,
98 layoutState: TextLayoutState,
99 updateSelectionState: (() -> Unit)?,
100 ): Int {
101 val rangeInTransformedText =
102 layoutState
103 .getRangeForScreenRect(
104 gesture.selectionArea.toComposeRect(),
105 gesture.granularity.toTextGranularity(),
106 TextInclusionStrategy.ContainsCenter
107 )
108 .apply { if (collapsed) return fallback(gesture) }
109
110 selectCharsIn(rangeInTransformedText)
111 updateSelectionState?.invoke()
112 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
113 }
114
TransformedTextFieldStatenull115 private fun TransformedTextFieldState.previewSelectGesture(
116 gesture: SelectGesture,
117 layoutState: TextLayoutState
118 ) {
119 highlightRange(
120 layoutState.getRangeForScreenRect(
121 gesture.selectionArea.toComposeRect(),
122 gesture.granularity.toTextGranularity(),
123 TextInclusionStrategy.ContainsCenter
124 ),
125 TextHighlightType.HandwritingSelectPreview
126 )
127 }
128
performDeleteGesturenull129 private fun TransformedTextFieldState.performDeleteGesture(
130 gesture: DeleteGesture,
131 layoutState: TextLayoutState
132 ): Int {
133 val granularity = gesture.granularity.toTextGranularity()
134 val rangeInTransformedText =
135 layoutState
136 .getRangeForScreenRect(
137 gesture.deletionArea.toComposeRect(),
138 granularity,
139 TextInclusionStrategy.ContainsCenter
140 )
141 .apply { if (collapsed) return fallback(gesture) }
142
143 performDeletion(
144 rangeInTransformedText = rangeInTransformedText,
145 adjustRange = (granularity == TextGranularity.Word)
146 )
147 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
148 }
149
previewDeleteGesturenull150 private fun TransformedTextFieldState.previewDeleteGesture(
151 gesture: DeleteGesture,
152 layoutState: TextLayoutState
153 ) {
154 highlightRange(
155 layoutState.getRangeForScreenRect(
156 gesture.deletionArea.toComposeRect(),
157 gesture.granularity.toTextGranularity(),
158 TextInclusionStrategy.ContainsCenter
159 ),
160 TextHighlightType.HandwritingDeletePreview
161 )
162 }
163
performSelectRangeGesturenull164 private fun TransformedTextFieldState.performSelectRangeGesture(
165 gesture: SelectRangeGesture,
166 layoutState: TextLayoutState,
167 updateSelectionState: (() -> Unit)?,
168 ): Int {
169 val rangeInTransformedText =
170 layoutState
171 .getRangeForScreenRects(
172 gesture.selectionStartArea.toComposeRect(),
173 gesture.selectionEndArea.toComposeRect(),
174 gesture.granularity.toTextGranularity(),
175 TextInclusionStrategy.ContainsCenter
176 )
177 .apply { if (collapsed) return fallback(gesture) }
178
179 selectCharsIn(rangeInTransformedText)
180 updateSelectionState?.invoke()
181 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
182 }
183
TransformedTextFieldStatenull184 private fun TransformedTextFieldState.previewSelectRangeGesture(
185 gesture: SelectRangeGesture,
186 layoutState: TextLayoutState
187 ) {
188 highlightRange(
189 layoutState.getRangeForScreenRects(
190 gesture.selectionStartArea.toComposeRect(),
191 gesture.selectionEndArea.toComposeRect(),
192 gesture.granularity.toTextGranularity(),
193 TextInclusionStrategy.ContainsCenter
194 ),
195 TextHighlightType.HandwritingSelectPreview
196 )
197 }
198
performDeleteRangeGesturenull199 private fun TransformedTextFieldState.performDeleteRangeGesture(
200 gesture: DeleteRangeGesture,
201 layoutState: TextLayoutState
202 ): Int {
203 val granularity = gesture.granularity.toTextGranularity()
204 val rangeInTransformedText =
205 layoutState
206 .getRangeForScreenRects(
207 gesture.deletionStartArea.toComposeRect(),
208 gesture.deletionEndArea.toComposeRect(),
209 granularity,
210 TextInclusionStrategy.ContainsCenter
211 )
212 .apply { if (collapsed) return fallback(gesture) }
213
214 performDeletion(
215 rangeInTransformedText = rangeInTransformedText,
216 adjustRange = granularity == TextGranularity.Word
217 )
218 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
219 }
220
previewDeleteRangeGesturenull221 private fun TransformedTextFieldState.previewDeleteRangeGesture(
222 gesture: DeleteRangeGesture,
223 layoutState: TextLayoutState
224 ) {
225 highlightRange(
226 layoutState.getRangeForScreenRects(
227 gesture.deletionStartArea.toComposeRect(),
228 gesture.deletionEndArea.toComposeRect(),
229 gesture.granularity.toTextGranularity(),
230 TextInclusionStrategy.ContainsCenter
231 ),
232 TextHighlightType.HandwritingDeletePreview
233 )
234 }
235
TransformedTextFieldStatenull236 private fun TransformedTextFieldState.performJoinOrSplitGesture(
237 gesture: JoinOrSplitGesture,
238 layoutState: TextLayoutState,
239 viewConfiguration: ViewConfiguration?
240 ): Int {
241 // Fail when there is an output transformation.
242 // If output transformation inserts some spaces to the text, we can't remove them.
243 // Do nothing is the best choice in this case.
244 // We don't fallback either because the user's intention is unlikely inserting characters.
245 if (outputText !== untransformedText) {
246 return InputConnection.HANDWRITING_GESTURE_RESULT_FAILED
247 }
248
249 val offset =
250 layoutState.getOffsetForHandwritingGesture(
251 pointInScreen = gesture.joinOrSplitPoint.toOffset(),
252 viewConfiguration = viewConfiguration
253 )
254
255 // TODO(332963121): support gesture at BiDi boundaries.
256 if (offset == -1 || layoutState.layoutResult?.isBiDiBoundary(offset) == true) {
257 return fallback(gesture)
258 }
259
260 val textRange = visualText.rangeOfWhitespaces(offset)
261
262 if (textRange.collapsed) {
263 replaceText(" ", textRange)
264 } else {
265 performDeletion(rangeInTransformedText = textRange, adjustRange = false)
266 }
267 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
268 }
269
performInsertGesturenull270 private fun TransformedTextFieldState.performInsertGesture(
271 gesture: InsertGesture,
272 layoutState: TextLayoutState,
273 viewConfiguration: ViewConfiguration?
274 ): Int {
275 val offset =
276 layoutState.getOffsetForHandwritingGesture(
277 pointInScreen = gesture.insertionPoint.toOffset(),
278 viewConfiguration = viewConfiguration
279 )
280
281 // TODO(332963121): support gesture at BiDi boundaries.
282 if (offset == -1) {
283 return fallback(gesture)
284 }
285
286 replaceText(gesture.textToInsert, TextRange(offset))
287 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
288 }
289
performRemoveSpaceGesturenull290 private fun TransformedTextFieldState.performRemoveSpaceGesture(
291 gesture: RemoveSpaceGesture,
292 layoutState: TextLayoutState,
293 viewConfiguration: ViewConfiguration?
294 ): Int {
295 val range =
296 layoutState.layoutResult
297 .getRangeForRemoveSpaceGesture(
298 startPointInScreen = gesture.startPoint.toOffset(),
299 endPointerInScreen = gesture.endPoint.toOffset(),
300 layoutCoordinates = layoutState.textLayoutNodeCoordinates,
301 viewConfiguration = viewConfiguration
302 )
303 .apply { if (collapsed) return fallback(gesture) }
304
305 var firstMatchStart = -1
306 var lastMatchEnd = -1
307 val newText =
308 visualText.substring(range).replace(Regex("\\s+")) {
309 if (firstMatchStart == -1) {
310 firstMatchStart = it.range.first
311 }
312 lastMatchEnd = it.range.last + 1
313 ""
314 }
315
316 // No whitespace is found in the target range, fallback instead
317 if (firstMatchStart == -1 || lastMatchEnd == -1) {
318 return fallback(gesture)
319 }
320
321 // We only replace the part of the text that changes.
322 // e.g. The text is "AB CD EF GH IJ" and the original gesture range is "CD EF GH"
323 // We'll replace " EF " to "EF" instead of replacing "CD EF GH" to "CDEFGH".
324 // By doing so, it'll place the cursor at the index of the last removed space.
325 // In the example above, the cursor should be placed after character 'F'.
326 val finalRange = TextRange(range.start + firstMatchStart, range.start + lastMatchEnd)
327 // Remove the unchanged part from the newText as well. Characters before firstMatchStart
328 // and characters after the lastMatchEnd are removed.
329 val finalNewText =
330 newText.substring(
331 startIndex = firstMatchStart,
332 endIndex = newText.length - (range.length - lastMatchEnd)
333 )
334
335 replaceText(finalNewText, finalRange)
336 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
337 }
338
performDeletionnull339 private fun TransformedTextFieldState.performDeletion(
340 rangeInTransformedText: TextRange,
341 adjustRange: Boolean
342 ) {
343 val finalRange =
344 if (adjustRange) {
345 rangeInTransformedText.adjustHandwritingDeleteGestureRange(visualText)
346 } else {
347 rangeInTransformedText
348 }
349 replaceText("", finalRange)
350 }
351
TransformedTextFieldStatenull352 private fun TransformedTextFieldState.fallback(gesture: HandwritingGesture): Int {
353 editUntransformedTextAsUser { clearHighlight() }
354
355 val fallbackText =
356 gesture.fallbackText ?: return InputConnection.HANDWRITING_GESTURE_RESULT_FAILED
357
358 replaceSelectedText(
359 newText = fallbackText,
360 clearComposition = true,
361 )
362 return InputConnection.HANDWRITING_GESTURE_RESULT_FALLBACK
363 }
364
highlightRangenull365 private fun TransformedTextFieldState.highlightRange(
366 range: TextRange,
367 type: TextHighlightType
368 ) {
369 if (range.collapsed) {
370 editUntransformedTextAsUser { clearHighlight() }
371 } else {
372 highlightCharsIn(type, range)
373 }
374 }
375
performHandwritingGesturenull376 internal fun LegacyTextFieldState.performHandwritingGesture(
377 gesture: HandwritingGesture,
378 textFieldSelectionManager: TextFieldSelectionManager?,
379 viewConfiguration: ViewConfiguration?,
380 editCommandConsumer: (EditCommand) -> Unit
381 ): Int {
382 val text = untransformedText ?: return InputConnection.HANDWRITING_GESTURE_RESULT_FAILED
383 if (text != layoutResult?.value?.layoutInput?.text) {
384 // The text is transformed or layout is null, handwriting gesture failed.
385 return InputConnection.HANDWRITING_GESTURE_RESULT_FAILED
386 }
387 return when (gesture) {
388 is SelectGesture ->
389 performSelectGesture(gesture, textFieldSelectionManager, editCommandConsumer)
390 is DeleteGesture -> performDeleteGesture(gesture, text, editCommandConsumer)
391 is SelectRangeGesture ->
392 performSelectRangeGesture(gesture, textFieldSelectionManager, editCommandConsumer)
393 is DeleteRangeGesture -> performDeleteRangeGesture(gesture, text, editCommandConsumer)
394 is JoinOrSplitGesture ->
395 performJoinOrSplitGesture(gesture, text, viewConfiguration, editCommandConsumer)
396 is InsertGesture ->
397 performInsertGesture(gesture, viewConfiguration, editCommandConsumer)
398 is RemoveSpaceGesture ->
399 performRemoveSpaceGesture(gesture, text, viewConfiguration, editCommandConsumer)
400 else -> InputConnection.HANDWRITING_GESTURE_RESULT_UNSUPPORTED
401 }
402 }
403
previewHandwritingGesturenull404 internal fun LegacyTextFieldState.previewHandwritingGesture(
405 gesture: PreviewableHandwritingGesture,
406 textFieldSelectionManager: TextFieldSelectionManager?,
407 cancellationSignal: CancellationSignal?
408 ): Boolean {
409 val text = untransformedText ?: return false
410 if (text != layoutResult?.value?.layoutInput?.text) {
411 // The text is transformed or layout is null, handwriting gesture failed.
412 return false
413 }
414 when (gesture) {
415 is SelectGesture -> previewSelectGesture(gesture, textFieldSelectionManager)
416 is DeleteGesture -> previewDeleteGesture(gesture, textFieldSelectionManager)
417 is SelectRangeGesture -> previewSelectRangeGesture(gesture, textFieldSelectionManager)
418 is DeleteRangeGesture -> previewDeleteRangeGesture(gesture, textFieldSelectionManager)
419 else -> return false
420 }
421 cancellationSignal?.setOnCancelListener {
422 textFieldSelectionManager?.clearPreviewHighlight()
423 }
424 return true
425 }
426
LegacyTextFieldStatenull427 private fun LegacyTextFieldState.performSelectGesture(
428 gesture: SelectGesture,
429 textSelectionManager: TextFieldSelectionManager?,
430 editCommandConsumer: (EditCommand) -> Unit
431 ): Int {
432 val range =
433 getRangeForScreenRect(
434 gesture.selectionArea.toComposeRect(),
435 gesture.granularity.toTextGranularity(),
436 TextInclusionStrategy.ContainsCenter
437 )
438 .apply {
439 if (collapsed) return fallbackOnLegacyTextField(gesture, editCommandConsumer)
440 }
441
442 performSelectionOnLegacyTextField(range, textSelectionManager, editCommandConsumer)
443 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
444 }
445
LegacyTextFieldStatenull446 private fun LegacyTextFieldState.previewSelectGesture(
447 gesture: SelectGesture,
448 textFieldSelectionManager: TextFieldSelectionManager?
449 ) {
450 textFieldSelectionManager?.setSelectionPreviewHighlight(
451 getRangeForScreenRect(
452 gesture.selectionArea.toComposeRect(),
453 gesture.granularity.toTextGranularity(),
454 TextInclusionStrategy.ContainsCenter
455 )
456 )
457 }
458
performDeleteGesturenull459 private fun LegacyTextFieldState.performDeleteGesture(
460 gesture: DeleteGesture,
461 text: AnnotatedString,
462 editCommandConsumer: (EditCommand) -> Unit
463 ): Int {
464 val granularity = gesture.granularity.toTextGranularity()
465 val range =
466 getRangeForScreenRect(
467 gesture.deletionArea.toComposeRect(),
468 granularity,
469 TextInclusionStrategy.ContainsCenter
470 )
471 .apply {
472 if (collapsed) return fallbackOnLegacyTextField(gesture, editCommandConsumer)
473 }
474
475 performDeletionOnLegacyTextField(
476 range = range,
477 text = text,
478 adjustRange = granularity == TextGranularity.Word,
479 editCommandConsumer = editCommandConsumer
480 )
481 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
482 }
483
previewDeleteGesturenull484 private fun LegacyTextFieldState.previewDeleteGesture(
485 gesture: DeleteGesture,
486 textFieldSelectionManager: TextFieldSelectionManager?
487 ) {
488 textFieldSelectionManager?.setDeletionPreviewHighlight(
489 getRangeForScreenRect(
490 gesture.deletionArea.toComposeRect(),
491 gesture.granularity.toTextGranularity(),
492 TextInclusionStrategy.ContainsCenter
493 )
494 )
495 }
496
performSelectRangeGesturenull497 private fun LegacyTextFieldState.performSelectRangeGesture(
498 gesture: SelectRangeGesture,
499 textSelectionManager: TextFieldSelectionManager?,
500 editCommandConsumer: (EditCommand) -> Unit
501 ): Int {
502 val range =
503 getRangeForScreenRects(
504 gesture.selectionStartArea.toComposeRect(),
505 gesture.selectionEndArea.toComposeRect(),
506 gesture.granularity.toTextGranularity(),
507 TextInclusionStrategy.ContainsCenter
508 )
509 .apply {
510 if (collapsed) return fallbackOnLegacyTextField(gesture, editCommandConsumer)
511 }
512
513 performSelectionOnLegacyTextField(
514 range = range,
515 textSelectionManager = textSelectionManager,
516 editCommandConsumer = editCommandConsumer
517 )
518 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
519 }
520
LegacyTextFieldStatenull521 private fun LegacyTextFieldState.previewSelectRangeGesture(
522 gesture: SelectRangeGesture,
523 textFieldSelectionManager: TextFieldSelectionManager?
524 ) {
525 textFieldSelectionManager?.setSelectionPreviewHighlight(
526 getRangeForScreenRects(
527 gesture.selectionStartArea.toComposeRect(),
528 gesture.selectionEndArea.toComposeRect(),
529 gesture.granularity.toTextGranularity(),
530 TextInclusionStrategy.ContainsCenter
531 )
532 )
533 }
534
performDeleteRangeGesturenull535 private fun LegacyTextFieldState.performDeleteRangeGesture(
536 gesture: DeleteRangeGesture,
537 text: AnnotatedString,
538 editCommandConsumer: (EditCommand) -> Unit
539 ): Int {
540 val granularity = gesture.granularity.toTextGranularity()
541 val range =
542 getRangeForScreenRects(
543 gesture.deletionStartArea.toComposeRect(),
544 gesture.deletionEndArea.toComposeRect(),
545 granularity,
546 TextInclusionStrategy.ContainsCenter
547 )
548 .apply {
549 if (collapsed) return fallbackOnLegacyTextField(gesture, editCommandConsumer)
550 }
551 performDeletionOnLegacyTextField(
552 range = range,
553 text = text,
554 adjustRange = granularity == TextGranularity.Word,
555 editCommandConsumer = editCommandConsumer
556 )
557 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
558 }
559
previewDeleteRangeGesturenull560 private fun LegacyTextFieldState.previewDeleteRangeGesture(
561 gesture: DeleteRangeGesture,
562 textFieldSelectionManager: TextFieldSelectionManager?
563 ) {
564 textFieldSelectionManager?.setDeletionPreviewHighlight(
565 getRangeForScreenRects(
566 gesture.deletionStartArea.toComposeRect(),
567 gesture.deletionEndArea.toComposeRect(),
568 gesture.granularity.toTextGranularity(),
569 TextInclusionStrategy.ContainsCenter
570 )
571 )
572 }
573
LegacyTextFieldStatenull574 private fun LegacyTextFieldState.performJoinOrSplitGesture(
575 gesture: JoinOrSplitGesture,
576 text: AnnotatedString,
577 viewConfiguration: ViewConfiguration?,
578 editCommandConsumer: (EditCommand) -> Unit
579 ): Int {
580 if (viewConfiguration == null) {
581 return fallbackOnLegacyTextField(gesture, editCommandConsumer)
582 }
583
584 val offset =
585 getOffsetForHandwritingGesture(
586 pointInScreen = gesture.joinOrSplitPoint.toOffset(),
587 viewConfiguration = viewConfiguration
588 )
589 // TODO(332963121): support gesture at BiDi boundaries.
590 if (offset == -1 || layoutResult?.value?.isBiDiBoundary(offset) == true) {
591 return fallbackOnLegacyTextField(gesture, editCommandConsumer)
592 }
593
594 val range = text.rangeOfWhitespaces(offset)
595 if (range.collapsed) {
596 performInsertionOnLegacyTextField(range.start, " ", editCommandConsumer)
597 } else {
598 performDeletionOnLegacyTextField(
599 range = range,
600 text = text,
601 adjustRange = false,
602 editCommandConsumer = editCommandConsumer
603 )
604 }
605
606 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
607 }
608
LegacyTextFieldStatenull609 private fun LegacyTextFieldState.performInsertGesture(
610 gesture: InsertGesture,
611 viewConfiguration: ViewConfiguration?,
612 editCommandConsumer: (EditCommand) -> Unit
613 ): Int {
614 if (viewConfiguration == null) {
615 return fallbackOnLegacyTextField(gesture, editCommandConsumer)
616 }
617
618 val offset =
619 getOffsetForHandwritingGesture(
620 pointInScreen = gesture.insertionPoint.toOffset(),
621 viewConfiguration = viewConfiguration
622 )
623 // TODO(332963121): support gesture at BiDi boundaries.
624 if (offset == -1 || layoutResult?.value?.isBiDiBoundary(offset) == true) {
625 return fallbackOnLegacyTextField(gesture, editCommandConsumer)
626 }
627
628 performInsertionOnLegacyTextField(offset, gesture.textToInsert, editCommandConsumer)
629 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
630 }
631
performRemoveSpaceGesturenull632 private fun LegacyTextFieldState.performRemoveSpaceGesture(
633 gesture: RemoveSpaceGesture,
634 text: AnnotatedString,
635 viewConfiguration: ViewConfiguration?,
636 editCommandConsumer: (EditCommand) -> Unit
637 ): Int {
638 val range =
639 layoutResult
640 ?.value
641 .getRangeForRemoveSpaceGesture(
642 startPointInScreen = gesture.startPoint.toOffset(),
643 endPointerInScreen = gesture.endPoint.toOffset(),
644 layoutCoordinates = layoutCoordinates,
645 viewConfiguration = viewConfiguration
646 )
647 .apply {
648 if (collapsed) return fallbackOnLegacyTextField(gesture, editCommandConsumer)
649 }
650
651 var firstMatchStart = -1
652 var lastMatchEnd = -1
653 val newText =
654 text.substring(range).replace(Regex("\\s+")) {
655 if (firstMatchStart == -1) {
656 firstMatchStart = it.range.first
657 }
658 lastMatchEnd = it.range.last + 1
659 ""
660 }
661
662 // No whitespace is found in the target range, fallback instead
663 if (firstMatchStart == -1 || lastMatchEnd == -1) {
664 return fallbackOnLegacyTextField(gesture, editCommandConsumer)
665 }
666
667 // We only replace the part of the text that changes.
668 // e.g. The text is "AB CD EF GH IJ" and the original gesture range is "CD EF GH"
669 // We'll replace " EF " to "EF" instead of replacing "CD EF GH" to "CDEFGH",
670 val replacedRangeStart = range.start + firstMatchStart
671 val replacedRangeEnd = range.start + lastMatchEnd
672 // Remove the unchanged part from the newText as well. Characters before firstMatchStart
673 // and characters after the lastMatchEnd are removed.
674 val finalNewText =
675 newText.substring(
676 startIndex = firstMatchStart,
677 endIndex = newText.length - (range.length - lastMatchEnd)
678 )
679
680 editCommandConsumer.invoke(
681 compoundEditCommand(
682 SetSelectionCommand(replacedRangeStart, replacedRangeEnd),
683 CommitTextCommand(finalNewText, 1)
684 )
685 )
686 return InputConnection.HANDWRITING_GESTURE_RESULT_SUCCESS
687 }
688
performInsertionOnLegacyTextFieldnull689 private fun performInsertionOnLegacyTextField(
690 offset: Int,
691 text: String,
692 editCommandConsumer: (EditCommand) -> Unit
693 ) {
694 editCommandConsumer.invoke(
695 compoundEditCommand(SetSelectionCommand(offset, offset), CommitTextCommand(text, 1))
696 )
697 }
698
performSelectionOnLegacyTextFieldnull699 private fun performSelectionOnLegacyTextField(
700 range: TextRange,
701 textSelectionManager: TextFieldSelectionManager?,
702 editCommandConsumer: (EditCommand) -> Unit
703 ) {
704 editCommandConsumer.invoke(SetSelectionCommand(range.start, range.end))
705 textSelectionManager?.enterSelectionMode(showFloatingToolbar = true)
706 }
707
performDeletionOnLegacyTextFieldnull708 private fun performDeletionOnLegacyTextField(
709 range: TextRange,
710 text: AnnotatedString,
711 adjustRange: Boolean,
712 editCommandConsumer: (EditCommand) -> Unit
713 ) {
714 val finalRange =
715 if (adjustRange) {
716 range.adjustHandwritingDeleteGestureRange(text)
717 } else {
718 range
719 }
720
721 editCommandConsumer.invoke(
722 compoundEditCommand(
723 SetSelectionCommand(finalRange.end, finalRange.end),
724 DeleteSurroundingTextCommand(
725 lengthAfterCursor = 0,
726 lengthBeforeCursor = finalRange.length
727 )
728 )
729 )
730 }
731
fallbackOnLegacyTextFieldnull732 private fun fallbackOnLegacyTextField(
733 gesture: HandwritingGesture,
734 editCommandConsumer: (EditCommand) -> Unit
735 ): Int {
736 val fallbackText =
737 gesture.fallbackText ?: return InputConnection.HANDWRITING_GESTURE_RESULT_FAILED
738 editCommandConsumer.invoke(CommitTextCommand(fallbackText, newCursorPosition = 1))
739 return InputConnection.HANDWRITING_GESTURE_RESULT_FALLBACK
740 }
741
742 /** Convert the Platform text granularity to Compose [TextGranularity] object. */
toTextGranularitynull743 private fun Int.toTextGranularity(): TextGranularity {
744 return when (this) {
745 HandwritingGesture.GRANULARITY_CHARACTER -> TextGranularity.Character
746 HandwritingGesture.GRANULARITY_WORD -> TextGranularity.Word
747 else -> TextGranularity.Character
748 }
749 }
750 }
751
752 private const val LINE_FEED_CODE_POINT = '\n'.code
753 private const val NBSP_CODE_POINT = '\u00A0'.code
754
755 /**
756 * For handwriting delete gestures with word granularity, adjust the start and end offsets to remove
757 * extra whitespace around the deleted text.
758 */
TextRangenull759 private fun TextRange.adjustHandwritingDeleteGestureRange(text: CharSequence): TextRange {
760 var start = this.start
761 var end = this.end
762
763 // If the deleted text is at the start of the text, the behavior is the same as the case
764 // where the deleted text follows a new line character.
765 var codePointBeforeStart: Int =
766 if (start > 0) {
767 Character.codePointBefore(text, start)
768 } else {
769 LINE_FEED_CODE_POINT
770 }
771 // If the deleted text is at the end of the text, the behavior is the same as the case where
772 // the deleted text precedes a new line character.
773 var codePointAtEnd: Int =
774 if (end < text.length) {
775 Character.codePointAt(text, end)
776 } else {
777 LINE_FEED_CODE_POINT
778 }
779
780 if (
781 codePointBeforeStart.isWhitespaceExceptNewline() &&
782 (codePointAtEnd.isWhitespace() || codePointAtEnd.isPunctuation())
783 ) {
784 // Remove whitespace (except new lines) before the deleted text, in these cases:
785 // - There is whitespace following the deleted text
786 // e.g. "one [deleted] three" -> "one | three" -> "one| three"
787 // - There is punctuation following the deleted text
788 // e.g. "one [deleted]!" -> "one |!" -> "one|!"
789 // - There is a new line following the deleted text
790 // e.g. "one [deleted]\n" -> "one |\n" -> "one|\n"
791 // - The deleted text is at the end of the text
792 // e.g. "one [deleted]" -> "one |" -> "one|"
793 // (The pipe | indicates the cursor position.)
794 do {
795 start -= Character.charCount(codePointBeforeStart)
796 if (start == 0) break
797 codePointBeforeStart = Character.codePointBefore(text, start)
798 } while (codePointBeforeStart.isWhitespaceExceptNewline())
799 return TextRange(start, end)
800 }
801
802 if (
803 codePointAtEnd.isWhitespaceExceptNewline() &&
804 (codePointBeforeStart.isWhitespace() || codePointBeforeStart.isPunctuation())
805 ) {
806 // Remove whitespace (except new lines) after the deleted text, in these cases:
807 // - There is punctuation preceding the deleted text
808 // e.g. "([deleted] two)" -> "(| two)" -> "(|two)"
809 // - There is a new line preceding the deleted text
810 // e.g. "\n[deleted] two" -> "\n| two" -> "\n|two"
811 // - The deleted text is at the start of the text
812 // e.g. "[deleted] two" -> "| two" -> "|two"
813 // (The pipe | indicates the cursor position.)
814 do {
815 end += Character.charCount(codePointAtEnd)
816 if (end == text.length) break
817 codePointAtEnd = Character.codePointAt(text, end)
818 } while (codePointAtEnd.isWhitespaceExceptNewline())
819 return TextRange(start, end)
820 }
821
822 // Return the original range.
823 return this
824 }
825
826 /**
827 * This helper method is copied from [android.text.TextUtils]. It returns true if the code point is
828 * a new line.
829 */
isNewlinenull830 private fun Int.isNewline(): Boolean {
831 val type = Character.getType(this)
832 return type == Character.PARAGRAPH_SEPARATOR.toInt() ||
833 type == Character.LINE_SEPARATOR.toInt() ||
834 this == LINE_FEED_CODE_POINT
835 }
836
837 /**
838 * This helper method is copied from [android.text.TextUtils]. It returns true if the code point is
839 * a whitespace.
840 */
isWhitespacenull841 private fun Int.isWhitespace(): Boolean {
842 return Character.isWhitespace(this) || this == NBSP_CODE_POINT
843 }
844
845 /**
846 * This helper method is copied from [android.text.TextUtils]. It returns true if the code point is
847 * a whitespace and not a new line.
848 */
isWhitespaceExceptNewlinenull849 private fun Int.isWhitespaceExceptNewline(): Boolean {
850 return isWhitespace() && !isNewline()
851 }
852
853 /**
854 * This helper method is copied from [android.text.TextUtils]. It returns true if the code point is
855 * a punctuation.
856 */
isPunctuationnull857 private fun Int.isPunctuation(): Boolean {
858 val type = Character.getType(this)
859 return type == Character.CONNECTOR_PUNCTUATION.toInt() ||
860 type == Character.DASH_PUNCTUATION.toInt() ||
861 type == Character.END_PUNCTUATION.toInt() ||
862 type == Character.FINAL_QUOTE_PUNCTUATION.toInt() ||
863 type == Character.INITIAL_QUOTE_PUNCTUATION.toInt() ||
864 type == Character.OTHER_PUNCTUATION.toInt() ||
865 type == Character.START_PUNCTUATION.toInt()
866 }
867
toOffsetnull868 private fun PointF.toOffset(): Offset = Offset(x, y)
869
870 private fun TextLayoutState.getRangeForScreenRect(
871 rectInScreen: Rect,
872 granularity: TextGranularity,
873 inclusionStrategy: TextInclusionStrategy
874 ): TextRange {
875 return layoutResult
876 ?.multiParagraph
877 .getRangeForScreenRect(
878 rectInScreen,
879 textLayoutNodeCoordinates,
880 granularity,
881 inclusionStrategy
882 )
883 }
884
TextLayoutStatenull885 private fun TextLayoutState.getRangeForScreenRects(
886 startRectInScreen: Rect,
887 endRectInScreen: Rect,
888 granularity: TextGranularity,
889 inclusionStrategy: TextInclusionStrategy
890 ): TextRange {
891 val startRange =
892 getRangeForScreenRect(startRectInScreen, granularity, inclusionStrategy).apply {
893 if (collapsed) return TextRange.Zero
894 }
895
896 val endRange =
897 getRangeForScreenRect(endRectInScreen, granularity, inclusionStrategy).apply {
898 if (collapsed) return TextRange.Zero
899 }
900
901 return enclosure(startRange, endRange)
902 }
903
LegacyTextFieldStatenull904 private fun LegacyTextFieldState.getRangeForScreenRect(
905 rectInScreen: Rect,
906 granularity: TextGranularity,
907 inclusionStrategy: TextInclusionStrategy
908 ): TextRange {
909 return layoutResult
910 ?.value
911 ?.multiParagraph
912 .getRangeForScreenRect(rectInScreen, layoutCoordinates, granularity, inclusionStrategy)
913 }
914
LegacyTextFieldStatenull915 private fun LegacyTextFieldState.getRangeForScreenRects(
916 startRectInScreen: Rect,
917 endRectInScreen: Rect,
918 granularity: TextGranularity,
919 inclusionStrategy: TextInclusionStrategy
920 ): TextRange {
921 val startRange =
922 getRangeForScreenRect(startRectInScreen, granularity, inclusionStrategy).apply {
923 if (collapsed) return TextRange.Zero
924 }
925
926 val endRange =
927 getRangeForScreenRect(endRectInScreen, granularity, inclusionStrategy).apply {
928 if (collapsed) return TextRange.Zero
929 }
930
931 return enclosure(startRange, endRange)
932 }
933
rangeOfWhitespacesnull934 private fun CharSequence.rangeOfWhitespaces(offset: Int): TextRange {
935 var startOffset = offset
936 var endOffset = offset
937
938 while (startOffset > 0) {
939 val codePointBeforeStart = codePointBefore(startOffset)
940 if (!codePointBeforeStart.isWhitespace()) {
941 break
942 }
943
944 startOffset -= Character.charCount(codePointBeforeStart)
945 }
946
947 while (endOffset < length) {
948 val codePointAtEnd = codePointAt(endOffset)
949 if (!codePointAtEnd.isWhitespace()) {
950 break
951 }
952 endOffset += charCount(codePointAtEnd)
953 }
954
955 return TextRange(startOffset, endOffset)
956 }
957
TextLayoutStatenull958 private fun TextLayoutState.getOffsetForHandwritingGesture(
959 pointInScreen: Offset,
960 viewConfiguration: ViewConfiguration?
961 ): Int {
962 return layoutResult
963 ?.multiParagraph
964 ?.getOffsetForHandwritingGesture(
965 pointInScreen,
966 textLayoutNodeCoordinates,
967 viewConfiguration
968 ) ?: -1
969 }
970
LegacyTextFieldStatenull971 private fun LegacyTextFieldState.getOffsetForHandwritingGesture(
972 pointInScreen: Offset,
973 viewConfiguration: ViewConfiguration
974 ): Int {
975 return layoutResult
976 ?.value
977 ?.multiParagraph
978 ?.getOffsetForHandwritingGesture(pointInScreen, layoutCoordinates, viewConfiguration) ?: -1
979 }
980
isBiDiBoundarynull981 private fun TextLayoutResult.isBiDiBoundary(offset: Int): Boolean {
982 val line = getLineForOffset(offset)
983 if (offset == getLineStart(line) || offset == getLineEnd(line)) {
984 return getParagraphDirection(offset) != getBidiRunDirection(offset)
985 }
986
987 // Offset can't 0 or text.length at the moment.
988 return getBidiRunDirection(offset) != getBidiRunDirection(offset - 1)
989 }
990
getRangeForScreenRectnull991 private fun MultiParagraph?.getRangeForScreenRect(
992 rectInScreen: Rect,
993 layoutCoordinates: LayoutCoordinates?,
994 granularity: TextGranularity,
995 inclusionStrategy: TextInclusionStrategy
996 ): TextRange {
997 if (this == null || layoutCoordinates == null) {
998 return TextRange.Zero
999 }
1000
1001 val screenOriginInLocal = layoutCoordinates.screenToLocal(Offset.Zero)
1002 val localRect = rectInScreen.translate(screenOriginInLocal)
1003 return getRangeForRect(localRect, granularity, inclusionStrategy)
1004 }
1005
getOffsetForHandwritingGesturenull1006 private fun MultiParagraph.getOffsetForHandwritingGesture(
1007 pointInScreen: Offset,
1008 layoutCoordinates: LayoutCoordinates?,
1009 viewConfiguration: ViewConfiguration?
1010 ): Int {
1011 val localPoint = layoutCoordinates?.screenToLocal(pointInScreen) ?: return -1
1012 val line = getLineForHandwritingGesture(localPoint, viewConfiguration)
1013 if (line == -1) return -1
1014
1015 val adjustedPoint = localPoint.copy(y = (getLineTop(line) + getLineBottom(line)) / 2f)
1016 return getOffsetForPosition(adjustedPoint)
1017 }
1018
TextLayoutResultnull1019 private fun TextLayoutResult?.getRangeForRemoveSpaceGesture(
1020 startPointInScreen: Offset,
1021 endPointerInScreen: Offset,
1022 layoutCoordinates: LayoutCoordinates?,
1023 viewConfiguration: ViewConfiguration?
1024 ): TextRange {
1025 if (this == null || layoutCoordinates == null) {
1026 return TextRange.Zero
1027 }
1028 val localStartPoint = layoutCoordinates.screenToLocal(startPointInScreen)
1029 val localEndPoint = layoutCoordinates.screenToLocal(endPointerInScreen)
1030 val startLine = multiParagraph.getLineForHandwritingGesture(localStartPoint, viewConfiguration)
1031 val endLine = multiParagraph.getLineForHandwritingGesture(localEndPoint, viewConfiguration)
1032 val line: Int
1033
1034 if (startLine == -1) {
1035 // Both start and end point are out of the line margin. Return null.
1036 if (endLine == -1) return TextRange.Zero
1037 line = endLine
1038 } else {
1039 line =
1040 if (endLine == -1) {
1041 startLine
1042 } else {
1043 // RemoveSpaceGesture is a single line gesture, it can't be applied for multiple
1044 // lines.
1045 // If start point and end point belongs to different lines, select the top line.
1046 min(startLine, endLine)
1047 }
1048 }
1049
1050 val lineCenter = (getLineTop(line) + getLineBottom(line)) / 2
1051
1052 val rect =
1053 Rect(
1054 left = min(localStartPoint.x, localEndPoint.x),
1055 top = lineCenter - 0.1f,
1056 right = max(localStartPoint.x, localEndPoint.x),
1057 bottom = lineCenter + 0.1f
1058 )
1059
1060 return multiParagraph.getRangeForRect(
1061 rect,
1062 TextGranularity.Character,
1063 TextInclusionStrategy.AnyOverlap
1064 )
1065 }
1066
getLineForHandwritingGesturenull1067 private fun MultiParagraph.getLineForHandwritingGesture(
1068 localPoint: Offset,
1069 viewConfiguration: ViewConfiguration?
1070 ): Int {
1071 val lineMargin = viewConfiguration?.handwritingGestureLineMargin ?: 0f
1072 val line = getLineForVerticalPosition(localPoint.y)
1073
1074 if (
1075 localPoint.y < getLineTop(line) - lineMargin ||
1076 localPoint.y > getLineBottom(line) + lineMargin
1077 ) {
1078 // The point is not within lineMargin of a line.
1079 return -1
1080 }
1081 if (localPoint.x < -lineMargin || localPoint.x > width + lineMargin) {
1082 // The point is not within lineMargin of a line.
1083 return -1
1084 }
1085 return line
1086 }
1087
compoundEditCommandnull1088 private fun compoundEditCommand(vararg editCommands: EditCommand): EditCommand {
1089 return object : EditCommand {
1090 override fun applyTo(buffer: EditingBuffer) {
1091 for (editCommand in editCommands) {
1092 editCommand.applyTo(buffer)
1093 }
1094 }
1095 }
1096 }
1097
1098 /** Return the minimum [TextRange] that contains the both given [TextRange]s. */
enclosurenull1099 private fun enclosure(a: TextRange, b: TextRange): TextRange {
1100 return TextRange(min(a.start, a.start), max(b.end, b.end))
1101 }
1102