1 /*
<lambda>null2 * 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.findFollowingBreak
20 import androidx.compose.foundation.text.findPrecedingBreak
21 import androidx.compose.foundation.text.getParagraphBoundary
22 import androidx.compose.ui.text.TextRange
23
24 /**
25 * Selection can be adjusted depends on context. For example, in touch mode dragging after a long
26 * press adjusts selection by word. But selection by dragging handles is character precise without
27 * adjustments. With a mouse, double-click selects by words and triple-clicks by paragraph.
28 *
29 * @see [SelectionRegistrar.notifySelectionUpdate]
30 */
31 internal fun interface SelectionAdjustment {
32
33 /**
34 * The callback function that is called once a new selection arrives, the return value of this
35 * function will be the final adjusted [Selection].
36 */
37 fun adjust(layout: SelectionLayout): Selection
38
39 companion object {
40 /**
41 * The selection adjustment that does nothing and directly return the input raw selection
42 * range.
43 */
44 val None = SelectionAdjustment { layout ->
45 Selection(
46 start = layout.startInfo.anchorForOffset(layout.startInfo.rawStartHandleOffset),
47 end = layout.endInfo.anchorForOffset(layout.endInfo.rawEndHandleOffset),
48 handlesCrossed = layout.crossStatus == CrossStatus.CROSSED
49 )
50 }
51
52 /**
53 * The character based selection. It normally won't change the raw selection range except
54 * when the input raw selection range is collapsed. In this case, it will almost always make
55 * sure at least one character is selected.
56 */
57 val Character = SelectionAdjustment { layout ->
58 None.adjust(layout).ensureAtLeastOneChar(layout)
59 }
60
61 /**
62 * The word based selection adjustment. It will adjust the raw input selection such that the
63 * selection boundary snap to the word boundary. It will always expand the raw input
64 * selection range to the closest word boundary. If the raw selection is reversed, it will
65 * always return a reversed selection, and vice versa.
66 */
67 val Word = SelectionAdjustment { layout ->
68 adjustToBoundaries(layout) { textLayoutResult.getWordBoundary(it) }
69 }
70
71 /**
72 * The paragraph based selection adjustment. It will adjust the raw input selection such
73 * that the selection boundary snap to the paragraph boundary. It will always expand the raw
74 * input selection range to the closest paragraph boundary. If the raw selection is
75 * reversed, it will always return a reversed selection, and vice versa.
76 */
77 val Paragraph = SelectionAdjustment { layout ->
78 adjustToBoundaries(layout) { inputText.getParagraphBoundary(it) }
79 }
80
81 /**
82 * A special version of character based selection that accelerates the selection update with
83 * word based selection. In short, it expands by word and shrinks by character. Here is more
84 * details of the behavior:
85 * 1. When previous selection is null, it will use word based selection.
86 * 2. When the start/end offset has moved to a different line/Text, it will use word based
87 * selection.
88 * 3. When the selection is shrinking, it behave same as the character based selection.
89 * Shrinking means that the start/end offset is moving in the direction that makes
90 * selected text shorter.
91 * 4. The selection boundary is expanding, a.if the previous start/end offset is not a word
92 * boundary, use character based selection. b.if the previous start/end offset is a word
93 * boundary, use word based selection.
94 *
95 * Notice that this selection adjustment assumes that when isStartHandle is true, only start
96 * handle is moving(or unchanged), and vice versa.
97 */
98 val CharacterWithWordAccelerate = SelectionAdjustment { layout ->
99 val previousSelection =
100 layout.previousSelection ?: return@SelectionAdjustment Word.adjust(layout)
101
102 val previousAnchor: Selection.AnchorInfo
103 val newAnchor: Selection.AnchorInfo
104 val startAnchor: Selection.AnchorInfo
105 val endAnchor: Selection.AnchorInfo
106
107 if (layout.isStartHandle) {
108 previousAnchor = previousSelection.start
109 newAnchor = layout.updateSelectionBoundary(layout.startInfo, previousAnchor)
110 startAnchor = newAnchor
111 endAnchor = previousSelection.end
112 } else {
113 previousAnchor = previousSelection.end
114 newAnchor = layout.updateSelectionBoundary(layout.endInfo, previousAnchor)
115 startAnchor = previousSelection.start
116 endAnchor = newAnchor
117 }
118
119 if (newAnchor == previousAnchor) {
120 // This avoids some cases in BiDi where `layout.crossed` is incorrect.
121 // In BiDi layout, a single character move gesture can result in the offset
122 // changing a large amount when crossing over from LTR -> RTL or visa versa.
123 // This can result in a layout which says it is crossed, but our new selection
124 // is uncrossed. Instead, just re-use the old selection.
125 // It also saves an allocation.
126 previousSelection
127 } else {
128 val crossed =
129 layout.crossStatus == CrossStatus.CROSSED ||
130 (layout.crossStatus == CrossStatus.COLLAPSED &&
131 startAnchor.offset > endAnchor.offset)
132 Selection(startAnchor, endAnchor, crossed).ensureAtLeastOneChar(layout)
133 }
134 }
135 }
136 }
137
138 /** @receiver The selection layout. It is expected that its previousSelection is non-null */
updateSelectionBoundarynull139 private fun SelectionLayout.updateSelectionBoundary(
140 info: SelectableInfo,
141 previousSelectionAnchor: Selection.AnchorInfo
142 ): Selection.AnchorInfo {
143 val currentRawOffset = if (isStartHandle) info.rawStartHandleOffset else info.rawEndHandleOffset
144
145 val currentSlot = if (isStartHandle) startSlot else endSlot
146 if (currentSlot != info.slot) {
147 // we are between Texts
148 return info.anchorForOffset(currentRawOffset)
149 }
150
151 val currentRawLine by
152 lazy(LazyThreadSafetyMode.NONE) { info.textLayoutResult.getLineForOffset(currentRawOffset) }
153
154 val otherRawOffset = if (isStartHandle) info.rawEndHandleOffset else info.rawStartHandleOffset
155
156 val anchorSnappedToWordBoundary by
157 lazy(LazyThreadSafetyMode.NONE) {
158 info.snapToWordBoundary(
159 currentLine = currentRawLine,
160 currentOffset = currentRawOffset,
161 otherOffset = otherRawOffset,
162 isStart = isStartHandle,
163 crossed = crossStatus == CrossStatus.CROSSED
164 )
165 }
166
167 if (info.selectableId != previousSelectionAnchor.selectableId) {
168 // moved to an entirely new Text, use word based adjustment
169 return anchorSnappedToWordBoundary
170 }
171
172 val rawPreviousHandleOffset = info.rawPreviousHandleOffset
173 if (currentRawOffset == rawPreviousHandleOffset) {
174 // no change in current handle, return the previous result unchanged
175 return previousSelectionAnchor
176 }
177
178 val previousRawLine = info.textLayoutResult.getLineForOffset(rawPreviousHandleOffset)
179 // Check raw lines. The previous adjusted selection offset could remain
180 // on a different line after snapping to the word boundary, causing the code to
181 // always seem like it is switching lines and never allowing it to not use the
182 // word boundary offset.
183 if (currentRawLine != previousRawLine) {
184 // Line changed, use word based adjustment.
185 return anchorSnappedToWordBoundary
186 }
187
188 val previousSelectionOffset = previousSelectionAnchor.offset
189 val previousSelectionWordBoundary =
190 info.textLayoutResult.getWordBoundary(previousSelectionOffset)
191
192 if (!info.isExpanding(currentRawOffset, isStartHandle)) {
193 // we're shrinking, use the raw offset.
194 return info.anchorForOffset(currentRawOffset)
195 }
196
197 if (
198 previousSelectionOffset == previousSelectionWordBoundary.start ||
199 previousSelectionOffset == previousSelectionWordBoundary.end
200 ) {
201 // We are expanding, and the previous offset was a word boundary,
202 // so continue using word boundaries.
203 return anchorSnappedToWordBoundary
204 }
205
206 // We're expanding, but our previousOffset was not at a word boundary. This means
207 // we are adjusting a selection within a word already, so continue to do so.
208 return info.anchorForOffset(currentRawOffset)
209 }
210
SelectableInfonull211 private fun SelectableInfo.isExpanding(currentRawOffset: Int, isStart: Boolean): Boolean {
212 if (rawPreviousHandleOffset == -1) {
213 return true
214 }
215 if (currentRawOffset == rawPreviousHandleOffset) {
216 return false
217 }
218
219 val crossed = rawCrossStatus == CrossStatus.CROSSED
220 return if (isStart xor crossed) {
221 currentRawOffset < rawPreviousHandleOffset
222 } else {
223 currentRawOffset > rawPreviousHandleOffset
224 }
225 }
226
snapToWordBoundarynull227 private fun SelectableInfo.snapToWordBoundary(
228 currentLine: Int,
229 currentOffset: Int,
230 otherOffset: Int,
231 isStart: Boolean,
232 crossed: Boolean,
233 ): Selection.AnchorInfo {
234 val wordBoundary = textLayoutResult.getWordBoundary(currentOffset)
235
236 // In the case where the target word crosses multiple lines due to hyphenation or
237 // being too long, we use the line start/end to keep the adjusted offset at the
238 // same line.
239 val wordStartLine = textLayoutResult.getLineForOffset(wordBoundary.start)
240 val start =
241 if (wordStartLine == currentLine) {
242 wordBoundary.start
243 } else if (currentLine >= textLayoutResult.lineCount) {
244 // We cannot find the line start, because this line is not even visible.
245 // Since we cannot really select meaningfully in this area,
246 // just use the start of the last visible line.
247 textLayoutResult.getLineStart(textLayoutResult.lineCount - 1)
248 } else {
249 textLayoutResult.getLineStart(currentLine)
250 }
251
252 val wordEndLine = textLayoutResult.getLineForOffset(wordBoundary.end)
253 val end =
254 if (wordEndLine == currentLine) {
255 wordBoundary.end
256 } else if (currentLine >= textLayoutResult.lineCount) {
257 // We cannot find the line end, because this line is not even visible.
258 // Since we cannot really select meaningfully in this area,
259 // just use the end of the last visible line.
260 textLayoutResult.getLineEnd(textLayoutResult.lineCount - 1)
261 } else {
262 textLayoutResult.getLineEnd(currentLine)
263 }
264
265 // If one of the word boundary is exactly same as the otherBoundaryOffset, we
266 // can't snap to this word boundary since it will result in an empty selection
267 // range.
268 if (start == otherOffset) {
269 return anchorForOffset(end)
270 }
271 if (end == otherOffset) {
272 return anchorForOffset(start)
273 }
274
275 val resultOffset =
276 if (isStart xor crossed) {
277 // In this branch when:
278 // 1. selection is updating the start offset, and selection is not reversed.
279 // 2. selection is updating the end offset, and selection is reversed.
280 if (currentOffset <= end) start else end
281 } else {
282 // In this branch when:
283 // 1. selection is updating the end offset, and selection is not reversed.
284 // 2. selection is updating the start offset, and selection is reversed.
285 if (currentOffset >= start) end else start
286 }
287
288 return anchorForOffset(resultOffset)
289 }
290
interfacenull291 private fun interface BoundaryFunction {
292 fun SelectableInfo.getBoundary(offset: Int): TextRange
293 }
294
adjustToBoundariesnull295 private fun adjustToBoundaries(
296 layout: SelectionLayout,
297 boundaryFunction: BoundaryFunction,
298 ): Selection {
299 val crossed = layout.crossStatus == CrossStatus.CROSSED
300 return Selection(
301 start =
302 layout.startInfo.anchorOnBoundary(
303 crossed = crossed,
304 isStart = true,
305 slot = layout.startSlot,
306 boundaryFunction = boundaryFunction,
307 ),
308 end =
309 layout.endInfo.anchorOnBoundary(
310 crossed = crossed,
311 isStart = false,
312 slot = layout.endSlot,
313 boundaryFunction = boundaryFunction,
314 ),
315 handlesCrossed = crossed
316 )
317 }
318
SelectableInfonull319 private fun SelectableInfo.anchorOnBoundary(
320 crossed: Boolean,
321 isStart: Boolean,
322 slot: Int,
323 boundaryFunction: BoundaryFunction,
324 ): Selection.AnchorInfo {
325 val offset = if (isStart) rawStartHandleOffset else rawEndHandleOffset
326
327 if (slot != this.slot) {
328 return anchorForOffset(offset)
329 }
330
331 val range = with(boundaryFunction) { getBoundary(offset) }
332
333 return anchorForOffset(if (isStart xor crossed) range.start else range.end)
334 }
335
336 /**
337 * This method adjusts the selection to one character respecting [String.findPrecedingBreak] and
338 * [String.findFollowingBreak].
339 */
ensureAtLeastOneCharnull340 internal fun Selection.ensureAtLeastOneChar(layout: SelectionLayout): Selection {
341 // There already is at least one char in this selection, return this selection unchanged.
342 if (!isCollapsed(layout)) {
343 return this
344 }
345
346 // Exceptions where 0 char selection is acceptable:
347 // - The selection crosses multiple Texts, but is still collapsed.
348 // - In the same situation in a single Text, we usually select some whitespace.
349 // Since there is no whitespace to select, select nothing. Expanding the selection
350 // into any Texts in this case is likely confusing to the user
351 // as it is different functionality compared to single text.
352 // - The previous selection is null, indicating this is the start of a selection.
353 // - This allows a selection to start off as collapsed. This is necessary for
354 // Character adjustment to allow an initial collapsed selection, and then once a
355 // non-collapsed selection is started, this exception goes away.
356 // - There is no text to select at all, so you can't expand anywhere.
357 val text = layout.currentInfo.inputText
358 if (layout.size > 1 || layout.previousSelection == null || text.isEmpty()) {
359 return this
360 }
361
362 return expandOneChar(layout)
363 }
364
365 /** Precondition: the selection is empty. */
expandOneCharnull366 private fun Selection.expandOneChar(layout: SelectionLayout): Selection {
367 val info = layout.currentInfo
368 val text = info.inputText
369 val offset = info.rawStartHandleOffset // start and end are the same, so either works
370
371 // when the offset is at either boundary of the text,
372 // expand the current handle one character into the text from the boundary.
373 val lastOffset = text.length
374 return when (offset) {
375 0 -> {
376 val followingBreak = text.findFollowingBreak(0)
377 if (layout.isStartHandle) {
378 copy(start = start.changeOffset(info, followingBreak), handlesCrossed = true)
379 } else {
380 copy(end = end.changeOffset(info, followingBreak), handlesCrossed = false)
381 }
382 }
383 lastOffset -> {
384 val precedingBreak = text.findPrecedingBreak(lastOffset)
385 if (layout.isStartHandle) {
386 copy(start = start.changeOffset(info, precedingBreak), handlesCrossed = false)
387 } else {
388 copy(end = end.changeOffset(info, precedingBreak), handlesCrossed = true)
389 }
390 }
391 else -> {
392 // In cases where offset is not along the boundary,
393 // we will try to maintain the current cross handle states.
394 val crossed = layout.previousSelection?.handlesCrossed == true
395 val newOffset =
396 if (layout.isStartHandle xor crossed) {
397 text.findPrecedingBreak(offset)
398 } else {
399 text.findFollowingBreak(offset)
400 }
401
402 if (layout.isStartHandle) {
403 copy(start = start.changeOffset(info, newOffset), handlesCrossed = crossed)
404 } else {
405 copy(end = end.changeOffset(info, newOffset), handlesCrossed = crossed)
406 }
407 }
408 }
409 }
410
411 // update direction when we are changing the offset since it may be different
changeOffsetnull412 private fun Selection.AnchorInfo.changeOffset(
413 info: SelectableInfo,
414 newOffset: Int,
415 ): Selection.AnchorInfo =
416 copy(offset = newOffset, direction = info.textLayoutResult.getBidiRunDirection(newOffset))
417