Allow reverse selection in the new EditingBuffer
This work adds handle crossing support to BTF2.
Test: EditingBufferTest
Test: :compose:foundation:foundation:cAT
Change-Id: Ib7af77f17cef9865d41ad49ada018f75694663f6
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandlesTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandlesTest.kt
index 0444338..a839bad 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandlesTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionHandlesTest.kt
@@ -656,7 +656,7 @@
}
@Test
- fun dragStartSelectionHandle_cannotExtendSelectionPastEndHandle() {
+ fun dragStartSelectionHandlePastEndHandle_reversesTheSelection() {
state = TextFieldState("abc def ghj", initialSelectionInChars = TextRange(4, 7))
rule.setContent {
BasicTextField2(
@@ -672,12 +672,12 @@
swipeToRight(Handle.SelectionStart, fontSizePx * 7)
rule.runOnIdle {
- assertThat(state.text.selectionInChars).isEqualTo(TextRange(7))
+ assertThat(state.text.selectionInChars).isEqualTo(TextRange(11, 7))
}
}
@Test
- fun dragEndSelectionHandle_cannotExtendSelectionPastStartHandle() {
+ fun dragEndSelectionHandlePastStartHandle_canReverseSelection() {
state = TextFieldState("abc def ghj", initialSelectionInChars = TextRange(4, 7))
rule.setContent {
BasicTextField2(
@@ -693,7 +693,7 @@
swipeToLeft(Handle.SelectionEnd, fontSizePx * 7)
rule.runOnIdle {
- assertThat(state.text.selectionInChars).isEqualTo(TextRange(4))
+ assertThat(state.text.selectionInChars).isEqualTo(TextRange(4, 0))
}
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt
index 2862d17..27ffb8e 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessor.kt
@@ -102,7 +102,7 @@
)
textChanged = true
} else if (bufferState.selectionInChars != newValue.selectionInChars) {
- mBuffer.setSelection(newValue.selectionInChars.min, newValue.selectionInChars.max)
+ mBuffer.setSelection(newValue.selectionInChars.start, newValue.selectionInChars.end)
selectionChanged = true
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/EditingBuffer.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/EditingBuffer.kt
index feeacb4..34a72d3 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/EditingBuffer.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/input/internal/EditingBuffer.kt
@@ -49,7 +49,7 @@
/**
* The inclusive selection start offset
*/
- var selectionStart = selection.min
+ var selectionStart = selection.start
private set(value) {
require(value >= 0) { "Cannot set selectionStart to a negative value: $value" }
field = value
@@ -58,7 +58,7 @@
/**
* The exclusive selection end offset
*/
- var selectionEnd = selection.max
+ var selectionEnd = selection.end
private set(value) {
require(value >= 0) { "Cannot set selectionEnd to a negative value: $value" }
field = value
@@ -135,23 +135,7 @@
) : this(AnnotatedString(text), selection)
init {
- val start = selection.min
- val end = selection.max
- if (start < 0 || start > text.length) {
- throw IndexOutOfBoundsException(
- "start ($start) offset is outside of text region ${text.length}"
- )
- }
-
- if (end < 0 || end > text.length) {
- throw IndexOutOfBoundsException(
- "end ($end) offset is outside of text region ${text.length}"
- )
- }
-
- if (start > end) {
- throw IllegalArgumentException("Do not set reversed range: $start > $end")
- }
+ checkRange(selection.start, selection.end)
}
fun replace(start: Int, end: Int, text: AnnotatedString) {
@@ -161,42 +145,30 @@
/**
* Replace the text and move the cursor to the end of inserted text.
*
- * This function cancels selection if there.
+ * This function cancels selection if there is any.
*
* @throws IndexOutOfBoundsException if start or end offset is outside of current buffer
* @throws IllegalArgumentException if start is larger than end. (reversed range)
*/
fun replace(start: Int, end: Int, text: String) {
+ checkRange(start, end)
+ val min = minOf(start, end)
+ val max = maxOf(start, end)
+
changeTracker.trackChange(TextRange(start, end), text.length)
- if (start < 0 || start > gapBuffer.length) {
- throw IndexOutOfBoundsException(
- "start ($start) offset is outside of text region ${gapBuffer.length}"
- )
- }
-
- if (end < 0 || end > gapBuffer.length) {
- throw IndexOutOfBoundsException(
- "end ($end) offset is outside of text region ${gapBuffer.length}"
- )
- }
-
- if (start > end) {
- throw IllegalArgumentException("Do not set reversed range: $start > $end")
- }
-
- gapBuffer.replace(start, end, text)
+ gapBuffer.replace(min, max, text)
// On Android, all text modification APIs also provides explicit cursor location. On the
- // hand, desktop application usually doesn't. So, here tentatively move the cursor to the
- // end offset of the editing area for desktop like application. In case of Android,
+ // other hand, desktop applications usually don't. So, here tentatively move the cursor to
+ // the end offset of the editing area for desktop like application. In case of Android,
// implementation will call setSelection immediately after replace function to update this
// tentative cursor location.
selectionStart = start + text.length
selectionEnd = start + text.length
- // Similarly, if text modification happens, cancel ongoing composition. If caller want to
- // change the composition text, it is caller responsibility to call setComposition again
+ // Similarly, if text modification happens, cancel ongoing composition. If caller wants to
+ // change the composition text, it is caller's responsibility to call setComposition again
// to set composition range after replace function.
compositionStart = NOWHERE
compositionEnd = NOWHERE
@@ -209,17 +181,19 @@
* Instead, preserve the selection with adjusting the deleted text.
*/
fun delete(start: Int, end: Int) {
- changeTracker.trackChange(TextRange(start, end), 0)
+ checkRange(start, end)
val deleteRange = TextRange(start, end)
- gapBuffer.replace(start, end, "")
+ changeTracker.trackChange(deleteRange, 0)
+
+ gapBuffer.replace(deleteRange.min, deleteRange.max, "")
val newSelection = updateRangeAfterDelete(
TextRange(selectionStart, selectionEnd),
deleteRange
)
- selectionStart = newSelection.min
- selectionEnd = newSelection.max
+ selectionStart = newSelection.start
+ selectionEnd = newSelection.end
if (hasComposition()) {
val compositionRange = TextRange(compositionStart, compositionEnd)
@@ -245,19 +219,7 @@
* @throws IllegalArgumentException if start is larger than end. (reversed range)
*/
fun setSelection(start: Int, end: Int) {
- if (start < 0 || start > gapBuffer.length) {
- throw IndexOutOfBoundsException(
- "start ($start) offset is outside of text region ${gapBuffer.length}"
- )
- }
- if (end < 0 || end > gapBuffer.length) {
- throw IndexOutOfBoundsException(
- "end ($end) offset is outside of text region ${gapBuffer.length}"
- )
- }
- if (start > end) {
- throw IllegalArgumentException("Do not set reversed range: $start > $end")
- }
+ checkRange(start, end)
selectionStart = start
selectionEnd = end
@@ -315,6 +277,24 @@
override fun toString(): String = gapBuffer.toString()
fun toAnnotatedString(): AnnotatedString = AnnotatedString(toString())
+
+ private fun checkRange(start: Int, end: Int) {
+ if (start < 0 || start > gapBuffer.length) {
+ throw IndexOutOfBoundsException(
+ "start ($start) offset is outside of text region ${gapBuffer.length}"
+ )
+ }
+
+ if (end < 0 || end > gapBuffer.length) {
+ throw IndexOutOfBoundsException(
+ "end ($end) offset is outside of text region ${gapBuffer.length}"
+ )
+ }
+
+ if (start > end) {
+ println("Setting reversed range: $start > $end")
+ }
+ }
}
/**
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionState.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionState.kt
index f6111aa..f94e21f 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionState.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text2/selection/TextFieldSelectionState.kt
@@ -319,15 +319,10 @@
previousHandleOffset = previousDragOffset,
adjustment = SelectionAdjustment.CharacterWithWordAccelerate,
)
- // selection drag should never reverse the selection. It can only collapse the
- // selection as the handles are about to pass each other.
- if (newSelection.reversed != prevSelection.reversed) {
- // the common offset should be the one that is not being dragged
- val offset = if (isStartHandle) prevSelection.end else prevSelection.start
- newSelection = TextRange(offset)
- }
- editWithFilter {
- selectCharsIn(newSelection)
+ if (prevSelection.collapsed || !newSelection.collapsed) {
+ editWithFilter {
+ selectCharsIn(newSelection)
+ }
}
previousDragOffset = if (isStartHandle) startOffset else endOffset
}
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessorTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessorTest.kt
index a8d8db8..7a5eba6 100644
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessorTest.kt
+++ b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/internal/EditProcessorTest.kt
@@ -165,8 +165,8 @@
val initialBuffer = processor.mBuffer
- assertThat(initialSelection.min).isEqualTo(initialBuffer.selectionStart)
- assertThat(initialSelection.max).isEqualTo(initialBuffer.selectionEnd)
+ assertThat(initialSelection.start).isEqualTo(initialBuffer.selectionStart)
+ assertThat(initialSelection.end).isEqualTo(initialBuffer.selectionEnd)
val updatedSelection = TextRange(3, 0)
val newTextFieldValue = TextFieldCharSequence(textFieldValue, selection = updatedSelection)
@@ -174,8 +174,8 @@
processor.reset(newTextFieldValue)
assertThat(processor.mBuffer).isSameInstanceAs(initialBuffer)
- assertThat(updatedSelection.min).isEqualTo(initialBuffer.selectionStart)
- assertThat(updatedSelection.max).isEqualTo(initialBuffer.selectionEnd)
+ assertThat(updatedSelection.start).isEqualTo(initialBuffer.selectionStart)
+ assertThat(updatedSelection.end).isEqualTo(initialBuffer.selectionEnd)
assertThat(resetCalled).isEqualTo(1)
}
diff --git a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/internal/EditingBufferTest.kt b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/internal/EditingBufferTest.kt
index 905d35f3..2a17df51 100644
--- a/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/internal/EditingBufferTest.kt
+++ b/compose/foundation/foundation/src/test/kotlin/androidx/compose/foundation/text2/input/internal/EditingBufferTest.kt
@@ -154,7 +154,15 @@
}
@Test
- fun setCompostion_and_cancelComposition() {
+ fun setSelection_allowReversedSelection() {
+ val eb = EditingBuffer("ABCDE", TextRange.Zero)
+ eb.setSelection(4, 2)
+
+ assertThat(eb.selection).isEqualTo(TextRange(4, 2))
+ }
+
+ @Test
+ fun setComposition_and_cancelComposition() {
val eb = EditingBuffer("ABCDE", TextRange.Zero)
eb.setComposition(0, 5) // Make all text as composition
@@ -195,7 +203,7 @@
}
@Test
- fun setCompostion_and_commitComposition() {
+ fun setComposition_and_commitComposition() {
val eb = EditingBuffer("ABCDE", TextRange.Zero)
eb.setComposition(0, 5) // Make all text as composition
@@ -334,6 +342,18 @@
}
@Test
+ fun delete_covered_reversedSelection() {
+ // A[BC]DE
+ val eb = EditingBuffer("ABCDE", TextRange(3, 1))
+
+ eb.delete(0, 4)
+ // []E
+ assertThat(eb).hasChars("E")
+ assertThat(eb.selectionStart).isEqualTo(0)
+ assertThat(eb.selectionEnd).isEqualTo(0)
+ }
+
+ @Test
fun delete_intersects_first_half_of_selection() {
// AB[CD]E
val eb = EditingBuffer("ABCDE", TextRange(2, 4))
@@ -346,6 +366,18 @@
}
@Test
+ fun delete_intersects_first_half_of_reversedSelection() {
+ // AB[CD]E
+ val eb = EditingBuffer("ABCDE", TextRange(4, 2))
+
+ eb.delete(3, 1)
+ // A[D]E
+ assertThat(eb).hasChars("ADE")
+ assertThat(eb.selectionStart).isEqualTo(1)
+ assertThat(eb.selectionEnd).isEqualTo(2)
+ }
+
+ @Test
fun delete_intersects_second_half_of_selection() {
// A[BCD]EFG
val eb = EditingBuffer("ABCDEFG", TextRange(1, 4))
@@ -358,6 +390,18 @@
}
@Test
+ fun delete_intersects_second_half_of_reversedSelection() {
+ // A[BCD]EFG
+ val eb = EditingBuffer("ABCDEFG", TextRange(4, 1))
+
+ eb.delete(5, 3)
+ // A[BC]FG
+ assertThat(eb).hasChars("ABCFG")
+ assertThat(eb.selectionStart).isEqualTo(1)
+ assertThat(eb.selectionEnd).isEqualTo(3)
+ }
+
+ @Test
fun delete_preceding_composition_no_intersection() {
val eb = EditingBuffer("ABCDE", TextRange.Zero)