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