1 /*
2  * Copyright 2021 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.text.Handle
20 import androidx.compose.runtime.Composable
21 import androidx.compose.ui.Alignment
22 import androidx.compose.ui.Modifier
23 import androidx.compose.ui.geometry.Offset
24 import androidx.compose.ui.geometry.takeOrElse
25 import androidx.compose.ui.semantics.SemanticsPropertyKey
26 import androidx.compose.ui.text.style.ResolvedTextDirection
27 import androidx.compose.ui.unit.DpSize
28 import androidx.compose.ui.unit.IntOffset
29 import androidx.compose.ui.unit.IntRect
30 import androidx.compose.ui.unit.IntSize
31 import androidx.compose.ui.unit.LayoutDirection
32 import androidx.compose.ui.unit.dp
33 import androidx.compose.ui.unit.round
34 import androidx.compose.ui.window.PopupPositionProvider
35 
36 internal val HandleWidth = 25.dp
37 internal val HandleHeight = 25.dp
38 
39 /**
40  * [SelectionHandleInfo]s for the nodes representing selection handles. These nodes are in popup
41  * windows, and will respond to drag gestures.
42  */
43 internal val SelectionHandleInfoKey =
44     SemanticsPropertyKey<SelectionHandleInfo>("SelectionHandleInfo")
45 
46 /**
47  * Information about a single selection handle popup.
48  *
49  * @param handle Which selection [Handle] this is about.
50  * @param position The position that the handle is anchored to relative to the selectable content.
51  *   This position is not necessarily the position of the popup itself, it's the position that the
52  *   handle "points" to (so e.g. top-middle for [Handle.Cursor]).
53  * @param anchor How the selection handle is anchored to its position
54  * @param visible Whether the icon of the handle is actually shown
55  */
56 internal data class SelectionHandleInfo(
57     val handle: Handle,
58     val position: Offset,
59     val anchor: SelectionHandleAnchor,
60     val visible: Boolean,
61 )
62 
63 /**
64  * How the selection handle is anchored to its position
65  *
66  * In a regular text selection, selection start is anchored to left. Only cursor handle is always
67  * anchored at the middle. In a regular text selection, selection end is anchored to right.
68  */
69 internal enum class SelectionHandleAnchor {
70     Left,
71     Middle,
72     Right
73 }
74 
75 @Composable
SelectionHandlenull76 internal expect fun SelectionHandle(
77     offsetProvider: OffsetProvider,
78     isStartHandle: Boolean,
79     direction: ResolvedTextDirection,
80     handlesCrossed: Boolean,
81     minTouchTargetSize: DpSize = DpSize.Unspecified,
82     lineHeight: Float,
83     modifier: Modifier,
84 )
85 
86 /** Avoids boxing of [Offset] which is an inline value class. */
87 internal fun interface OffsetProvider {
88     fun provide(): Offset
89 }
90 
91 /**
92  * Adjust coordinates for given text offset.
93  *
94  * Currently [android.text.Layout.getLineBottom] returns y coordinates of the next line's top
95  * offset, which is not included in current line's hit area. To be able to hit current line, move up
96  * this y coordinates by 1 pixel.
97  */
getAdjustedCoordinatesnull98 internal fun getAdjustedCoordinates(position: Offset): Offset {
99     return Offset(position.x, position.y - 1f)
100 }
101 
102 /**
103  * This [PopupPositionProvider] for a selection handle. It will position the selection handle to the
104  * result of [positionProvider] in its anchor layout.
105  */
106 internal class HandlePositionProvider(
107     private val handleReferencePoint: Alignment,
108     private val positionProvider: OffsetProvider,
109 ) : PopupPositionProvider {
110 
111     /**
112      * When Handle disappears, it starts reporting its position as [Offset.Unspecified]. Normally,
113      * Popup is dismissed immediately when its position becomes unspecified, but for one frame a
114      * position update might be requested by soon-to-be-destroyed Popup. In this case, report the
115      * last known position as there are no more updates. If the first ever position is provided as
116      * unspecified, start with [Offset.Zero] default.
117      */
118     private var prevPosition: Offset = Offset.Zero
119 
calculatePositionnull120     override fun calculatePosition(
121         anchorBounds: IntRect,
122         windowSize: IntSize,
123         layoutDirection: LayoutDirection,
124         popupContentSize: IntSize
125     ): IntOffset {
126         val position = positionProvider.provide().takeOrElse { prevPosition }
127         prevPosition = position
128 
129         val adjustment = handleReferencePoint.align(popupContentSize, IntSize.Zero, layoutDirection)
130         return anchorBounds.topLeft + position.round() + adjustment
131     }
132 }
133 
134 /** Computes whether the handle's appearance should be left-pointing or right-pointing. */
isLeftSelectionHandlenull135 internal fun isLeftSelectionHandle(
136     isStartHandle: Boolean,
137     direction: ResolvedTextDirection,
138     handlesCrossed: Boolean
139 ): Boolean {
140     return if (isStartHandle) {
141         isHandleLtrDirection(direction, handlesCrossed)
142     } else {
143         !isHandleLtrDirection(direction, handlesCrossed)
144     }
145 }
146 
147 /**
148  * This method is to check if the selection handles should use the natural Ltr pointing direction.
149  * If the context is Ltr and the handles are not crossed, or if the context is Rtl and the handles
150  * are crossed, return true.
151  *
152  * In Ltr context, the start handle should point to the left, and the end handle should point to the
153  * right. However, in Rtl context or when handles are crossed, the start handle should point to the
154  * right, and the end handle should point to left.
155  */
156 /*@VisibleForTesting*/
isHandleLtrDirectionnull157 internal fun isHandleLtrDirection(
158     direction: ResolvedTextDirection,
159     areHandlesCrossed: Boolean
160 ): Boolean {
161     return direction == ResolvedTextDirection.Ltr && !areHandlesCrossed ||
162         direction == ResolvedTextDirection.Rtl && areHandlesCrossed
163 }
164