| /* |
| * Copyright 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.compose.foundation.text.selection |
| |
| import androidx.ui.core.LayoutCoordinates |
| import androidx.ui.core.selection.Selectable |
| import androidx.ui.core.selection.Selection |
| import androidx.ui.geometry.Offset |
| import androidx.ui.geometry.Rect |
| import androidx.ui.text.AnnotatedString |
| import androidx.ui.text.TextLayoutResult |
| import androidx.ui.text.TextRange |
| import kotlin.math.max |
| |
| internal class MultiWidgetSelectionDelegate( |
| private val selectionRangeUpdate: (TextRange?) -> Unit, |
| private val coordinatesCallback: () -> LayoutCoordinates?, |
| private val layoutResultCallback: () -> TextLayoutResult? |
| ) : Selectable { |
| override fun getSelection( |
| startPosition: Offset, |
| endPosition: Offset, |
| containerLayoutCoordinates: LayoutCoordinates, |
| longPress: Boolean, |
| previousSelection: Selection?, |
| isStartHandle: Boolean |
| ): Selection? { |
| val layoutCoordinates = getLayoutCoordinates() ?: return null |
| val textLayoutResult = layoutResultCallback() ?: return null |
| |
| val relativePosition = containerLayoutCoordinates.childToLocal( |
| layoutCoordinates, Offset.Zero |
| ) |
| val startPx = startPosition - relativePosition |
| val endPx = endPosition - relativePosition |
| |
| val selection = getTextSelectionInfo( |
| textLayoutResult = textLayoutResult, |
| selectionCoordinates = Pair(startPx, endPx), |
| selectable = this, |
| wordBasedSelection = longPress, |
| previousSelection = previousSelection, |
| isStartHandle = isStartHandle |
| ) |
| |
| return if (selection == null) { |
| selectionRangeUpdate(null) |
| null |
| } else { |
| selectionRangeUpdate(selection.toTextRange()) |
| return selection |
| } |
| } |
| |
| override fun getHandlePosition(selection: Selection, isStartHandle: Boolean): Offset { |
| // Check if the selection handles's selectable is the current selectable. |
| if (isStartHandle && selection.start.selectable != this || |
| !isStartHandle && selection.end.selectable != this) { |
| return Offset.Zero |
| } |
| |
| if (getLayoutCoordinates() == null) return Offset.Zero |
| |
| val textLayoutResult = layoutResultCallback() ?: return Offset.Zero |
| return getSelectionHandleCoordinates( |
| textLayoutResult = textLayoutResult, |
| offset = if (isStartHandle) selection.start.offset else selection.end.offset, |
| isStart = isStartHandle, |
| areHandlesCrossed = selection.handlesCrossed |
| ) |
| } |
| |
| override fun getLayoutCoordinates(): LayoutCoordinates? { |
| val layoutCoordinates = coordinatesCallback() |
| if (layoutCoordinates == null || !layoutCoordinates.isAttached) return null |
| return layoutCoordinates |
| } |
| |
| override fun getText(): AnnotatedString { |
| val textLayoutResult = layoutResultCallback() ?: return AnnotatedString("") |
| return textLayoutResult.layoutInput.text |
| } |
| |
| override fun getBoundingBox(offset: Int): Rect { |
| val textLayoutResult = layoutResultCallback() ?: return Rect.zero |
| return textLayoutResult.getBoundingBox( |
| offset.coerceIn( |
| 0, |
| textLayoutResult.layoutInput.text.text.length - 1 |
| ) |
| ) |
| } |
| } |
| |
| /** |
| * Return information about the current selection in the Text. |
| * |
| * @param textLayoutResult a result of the text layout. |
| * @param selectionCoordinates The positions of the start and end of the selection in Text |
| * composable coordinate system. |
| * @param selectable current [Selectable] for which the [Selection] is being calculated |
| * @param wordBasedSelection This flag is ignored if the selection handles are being dragged. If |
| * the selection is modified by long press and drag gesture, the result selection will be |
| * adjusted to word based selection. Otherwise, the selection will be adjusted to character based |
| * selection. |
| * @param previousSelection previous selection result |
| * @param isStartHandle true if the start handle is being dragged |
| * |
| * @return [Selection] of the current composable, or null if the composable is not selected. |
| */ |
| internal fun getTextSelectionInfo( |
| textLayoutResult: TextLayoutResult, |
| selectionCoordinates: Pair<Offset, Offset>, |
| selectable: Selectable, |
| wordBasedSelection: Boolean, |
| previousSelection: Selection? = null, |
| isStartHandle: Boolean = true |
| ): Selection? { |
| val startPosition = selectionCoordinates.first |
| val endPosition = selectionCoordinates.second |
| |
| val bounds = Rect( |
| 0.0f, |
| 0.0f, |
| textLayoutResult.size.width.toFloat(), |
| textLayoutResult.size.height.toFloat() |
| ) |
| |
| val lastOffset = textLayoutResult.layoutInput.text.text.length |
| |
| val containsWholeSelectionStart = |
| bounds.contains(Offset(startPosition.x, startPosition.y)) |
| |
| val containsWholeSelectionEnd = |
| bounds.contains(Offset(endPosition.x, endPosition.y)) |
| |
| val rawStartOffset = |
| if (containsWholeSelectionStart) |
| textLayoutResult.getOffsetForPosition(startPosition).coerceIn(0, lastOffset) |
| else |
| // If the composable is selected, the start offset cannot be -1 for this composable. If the |
| // final start offset is still -1, it means this composable is not selected. |
| -1 |
| val rawEndOffset = |
| if (containsWholeSelectionEnd) |
| textLayoutResult.getOffsetForPosition(endPosition).coerceIn(0, lastOffset) |
| else |
| // If the composable is selected, the end offset cannot be -1 for this composable. If the |
| // final end offset is still -1, it means this composable is not selected. |
| -1 |
| |
| return getRefinedSelectionInfo( |
| rawStartOffset = rawStartOffset, |
| rawEndOffset = rawEndOffset, |
| containsWholeSelectionStart = containsWholeSelectionStart, |
| containsWholeSelectionEnd = containsWholeSelectionEnd, |
| startPosition = startPosition, |
| endPosition = endPosition, |
| bounds = bounds, |
| textLayoutResult = textLayoutResult, |
| lastOffset = lastOffset, |
| selectable = selectable, |
| wordBasedSelection = wordBasedSelection, |
| previousSelection = previousSelection, |
| isStartHandle = isStartHandle |
| ) |
| } |
| |
| /** |
| * This method refines the selection info by processing the initial raw selection info. |
| * |
| * @param rawStartOffset unprocessed start offset calculated directly from input position |
| * @param rawEndOffset unprocessed end offset calculated directly from input position |
| * @param containsWholeSelectionStart a flag to check if current composable contains the overall |
| * selection start |
| * @param containsWholeSelectionEnd a flag to check if current composable contains the overall |
| * selection end |
| * @param startPosition graphical position of the start of the selection, in composable's |
| * coordinates. |
| * @param endPosition graphical position of the end of the selection, in composable's coordinates. |
| * @param bounds bounds of the current composable |
| * @param textLayoutResult a result of the text layout. |
| * @param lastOffset last offset of the text. It's actually the length of the text. |
| * @param selectable current [Selectable] for which the [Selection] is being calculated |
| * @param wordBasedSelection This flag is ignored if the selection handles are being dragged. If |
| * the selection is modified by long press and drag gesture, the result selection will be |
| * adjusted to word based selection. Otherwise, the selection will be adjusted to character based |
| * selection. |
| * @param previousSelection previous selection result |
| * @param isStartHandle true if the start handle is being dragged |
| * |
| * @return [Selection] of the current composable, or null if the composable is not selected. |
| */ |
| private fun getRefinedSelectionInfo( |
| rawStartOffset: Int, |
| rawEndOffset: Int, |
| containsWholeSelectionStart: Boolean, |
| containsWholeSelectionEnd: Boolean, |
| startPosition: Offset, |
| endPosition: Offset, |
| bounds: Rect, |
| textLayoutResult: TextLayoutResult, |
| lastOffset: Int, |
| selectable: Selectable, |
| wordBasedSelection: Boolean, |
| previousSelection: Selection? = null, |
| isStartHandle: Boolean = true |
| ): Selection? { |
| val shouldProcessAsSinglecomposable = |
| containsWholeSelectionStart && containsWholeSelectionEnd |
| |
| var (startOffset, endOffset, handlesCrossed) = |
| if (shouldProcessAsSinglecomposable) { |
| processAsSingleComposable( |
| rawStartOffset = rawStartOffset, |
| rawEndOffset = rawEndOffset, |
| previousSelection = previousSelection?.toTextRange(), |
| isStartHandle = isStartHandle, |
| lastOffset = lastOffset, |
| handlesCrossed = previousSelection?.handlesCrossed ?: false |
| ) |
| } else { |
| processCrossComposable( |
| startPosition = startPosition, |
| endPosition = endPosition, |
| rawStartOffset = rawStartOffset, |
| rawEndOffset = rawEndOffset, |
| lastOffset = lastOffset, |
| bounds = bounds, |
| containsWholeSelectionStart = containsWholeSelectionStart, |
| containsWholeSelectionEnd = containsWholeSelectionEnd |
| ) |
| } |
| // nothing is selected |
| if (startOffset == -1 && endOffset == -1) return null |
| |
| // If under long press, update the selection to word-based. |
| if (wordBasedSelection) { |
| val (start, end) = updateWordBasedSelection( |
| textLayoutResult = textLayoutResult, |
| startOffset = startOffset, |
| endOffset = endOffset, |
| handlesCrossed = handlesCrossed |
| ) |
| startOffset = start |
| endOffset = end |
| } |
| |
| return getAssembledSelectionInfo( |
| startOffset = startOffset, |
| endOffset = endOffset, |
| handlesCrossed = handlesCrossed, |
| selectable = selectable, |
| textLayoutResult = textLayoutResult |
| ) |
| } |
| |
| /** |
| * [Selection] contains a lot of parameters. It looks more clean to assemble an object of this |
| * class in a separate method. |
| * |
| * @param startOffset the final start offset to be returned. |
| * @param endOffset the final end offset to be returned. |
| * @param handlesCrossed true if the selection handles are crossed |
| * @param selectable current [Selectable] for which the [Selection] is being calculated |
| * @param textLayoutResult a result of the text layout. |
| * |
| * @return an assembled object of [Selection] using the offered selection info. |
| */ |
| private fun getAssembledSelectionInfo( |
| startOffset: Int, |
| endOffset: Int, |
| handlesCrossed: Boolean, |
| selectable: Selectable, |
| textLayoutResult: TextLayoutResult |
| ): Selection { |
| return Selection( |
| start = Selection.AnchorInfo( |
| direction = textLayoutResult.getBidiRunDirection(startOffset), |
| offset = startOffset, |
| selectable = selectable |
| ), |
| end = Selection.AnchorInfo( |
| direction = textLayoutResult.getBidiRunDirection(max(endOffset - 1, 0)), |
| offset = endOffset, |
| selectable = selectable |
| ), |
| handlesCrossed = handlesCrossed |
| ) |
| } |