diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt index 82d23ce497..e572269bb2 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt @@ -26,8 +26,12 @@ import android.util.AttributeSet import android.view.KeyEvent import android.view.MotionEvent import android.view.View +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityManager +import android.view.accessibility.AccessibilityNodeInfo import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection +import android.widget.EditText import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import com.blankj.utilcode.util.FileUtils @@ -52,6 +56,7 @@ import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider import com.itsaky.androidide.editor.snippets.AbstractSnippetVariableResolver import com.itsaky.androidide.editor.snippets.FileVariableResolver import com.itsaky.androidide.editor.snippets.WorkspaceVariableResolver +import com.itsaky.androidide.editor.utils.EditorAccessibilitySegments import com.itsaky.androidide.eventbus.events.editor.ChangeType import com.itsaky.androidide.eventbus.events.editor.ColorSchemeInvalidatedEvent import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent @@ -620,6 +625,287 @@ constructor( } } + // region Accessibility (screen reader) cursor synchronization + // + // The base CodeEditor exposes its whole content to accessibility services as a single + // editable node, but does not advertise text-movement granularities, ACTION_SET_SELECTION + // or the granularity actions. As a result TalkBack cannot keep the real text cursor in sync + // with the reading position: navigating or double-tapping placed the cursor in the wrong + // location and typing landed on the wrong line. The overrides below make the editor behave + // like a standard EditText for screen readers. + + private val accessibilityManager: AccessibilityManager? by lazy { + context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager + } + + // Last touch-exploration (hover) position, in view coordinates, used to place the cursor + // where the user double-taps under TalkBack. NaN means "no recent exploration". + private var lastHoverX = Float.NaN + private var lastHoverY = Float.NaN + + private val isTouchExplorationEnabled: Boolean + get() = accessibilityManager?.isTouchExplorationEnabled == true + + override fun getAccessibilityClassName(): CharSequence = + // Only advertise EditText semantics in the same states createAccessibilityNodeInfo() + // does, so a read-only or released editor is not announced as an "Edit box". + if (!isReleased && isEnabled && isEditable) { + EditText::class.java.name + } else { + super.getAccessibilityClassName() + } + + override fun createAccessibilityNodeInfo(): AccessibilityNodeInfo? { + val info = super.createAccessibilityNodeInfo() ?: return null + if (!isReleased && isEnabled && isEditable) { + // Report as an EditText so TalkBack engages its text-editing affordances and keeps + // the caret in sync with granular navigation. + info.className = EditText::class.java.name + info.movementGranularities = EditorAccessibilitySegments.SUPPORTED_GRANULARITIES + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_SELECTION) + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_NEXT_AT_MOVEMENT_GRANULARITY) + info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY) + } + return info + } + + override fun dispatchHoverEvent(event: MotionEvent): Boolean { + // Remember where the user is exploring so ACTION_CLICK can place the caret there. + if (isTouchExplorationEnabled) { + lastHoverX = event.x + lastHoverY = event.y + } + return super.dispatchHoverEvent(event) + } + + override fun performAccessibilityAction( + action: Int, + arguments: Bundle?, + ): Boolean { + when (action) { + AccessibilityNodeInfo.ACTION_SET_SELECTION -> + if (handleAccessibilitySetSelection(arguments)) return true + + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY -> + if (handleAccessibilityGranularityMove(action, arguments, forward = true)) return true + + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY -> + if (handleAccessibilityGranularityMove(action, arguments, forward = false)) return true + + AccessibilityNodeInfo.ACTION_CLICK -> + if (handleAccessibilityClick()) return true + } + return super.performAccessibilityAction(action, arguments) + } + + /** + * Places the caret where the user last explored by touch. This makes a TalkBack double-tap + * on a word or line move the real text cursor to that spot instead of the node's center. + */ + private fun handleAccessibilityClick(): Boolean { + if (isReleased || !isEditable || !isTouchExplorationEnabled) return false + val x = lastHoverX + val y = lastHoverY + if (x.isNaN() || y.isNaN()) return false + return try { + if (!isFocused) requestFocus() + val packed = getPointPositionOnScreen(x, y) + val line = (packed ushr 32).toInt() + val column = (packed and 0xffffffffL).toInt() + if (line < 0 || column < 0) return false + setSelection(line, column) + true + } catch (e: IndexOutOfBoundsException) { + log.error("Error placing cursor from accessibility click", e) + false + } catch (e: IllegalArgumentException) { + log.error("Error placing cursor from accessibility click", e) + false + } + } + + /** Handles [AccessibilityNodeInfo.ACTION_SET_SELECTION] using absolute character indices. */ + private fun handleAccessibilitySetSelection(arguments: Bundle?): Boolean { + if (isReleased || !isEditable || arguments == null) return false + if (!arguments.containsKey(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT) || + !arguments.containsKey(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT) + ) { + return false + } + val content = text + val length = content.length + val start = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT) + val end = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT) + if (start < 0 || end < 0 || start > length || end > length) return false + return applySelectionFromIndices(start, end) + } + + /** + * Handles the move-by-granularity actions. Moves the real text cursor to the next/previous + * character, word or line and reports the traversed segment so the screen reader reads it, + * mirroring [EditText] behaviour. + */ + private fun handleAccessibilityGranularityMove( + action: Int, + arguments: Bundle?, + forward: Boolean, + ): Boolean { + if (isReleased || !isEditable || arguments == null) return false + val granularity = arguments.getInt( + AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, + ) + val extendSelection = arguments.getBoolean( + AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, + false, + ) + val content = text + val cur = cursor ?: return false + val selStart = cur.left + val selEnd = cur.right + + val fromIndex = if (forward) selEnd else selStart + val segment = + if (forward) { + EditorAccessibilitySegments.following(content, granularity, fromIndex) + } else { + EditorAccessibilitySegments.preceding(content, granularity, fromIndex) + } ?: return false + + val segStart = segment[0] + val segEnd = segment[1] + val newCaret = if (forward) segEnd else segStart + + val applied = + if (extendSelection) { + val anchor = if (forward) selStart else selEnd + applySelectionFromIndices(minOf(anchor, newCaret), maxOf(anchor, newCaret)) + } else { + applySelectionFromIndices(newCaret, newCaret) + } + if (!applied) return false + + sendTextTraversedEvent(segStart, segEnd, action, granularity) + return true + } + + /** Converts absolute char indices to (line, column) positions and moves the selection. */ + private fun applySelectionFromIndices(start: Int, end: Int): Boolean { + val content = text + return try { + val indexer = content.indexer + val startPos = indexer.getCharPosition(start) + if (start == end) { + setSelection(startPos.line, startPos.column) + } else { + val endPos = indexer.getCharPosition(end) + setSelectionRegion(startPos.line, startPos.column, endPos.line, endPos.column) + } + true + } catch (e: IndexOutOfBoundsException) { + log.error("Error applying accessibility selection [$start, $end]", e) + false + } catch (e: IllegalArgumentException) { + log.error("Error applying accessibility selection [$start, $end]", e) + false + } + } + + /** + * Emits a [AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY] event so the + * screen reader announces the character/word/line the caret just traversed. + */ + @Suppress("DEPRECATION") // AccessibilityEvent(int) requires API 30; obtain() works on minSdk 28. + private fun sendTextTraversedEvent( + fromIndex: Int, + toIndex: Int, + action: Int, + granularity: Int, + ) { + val manager = accessibilityManager ?: return + if (!manager.isEnabled) return + val event = AccessibilityEvent.obtain( + AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY, + ) + onInitializeAccessibilityEvent(event) + event.className = EditText::class.java.name + event.fromIndex = fromIndex + event.toIndex = toIndex + event.action = action + event.movementGranularity = granularity + // The reader substrings [fromIndex, toIndex] out of the event text. + event.text.add(text.toString()) + sendAccessibilityEventUnchecked(event) + } + + /** + * Emits a [AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED] event so the screen reader + * tracks the caret/selection as it moves — required for reliable movement-by-granularity + * navigation (lines/paragraphs in particular) and to mirror standard [EditText] behaviour. + * Called on every selection change; cheap no-op when no accessibility service is running. + */ + @Suppress("DEPRECATION") + fun sendSelectionChangedAccessibilityEvent() { + val manager = accessibilityManager ?: return + if (!manager.isEnabled) return + val cur = cursor ?: return + val event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) + onInitializeAccessibilityEvent(event) + event.className = EditText::class.java.name + val content = text.toString() + event.fromIndex = cur.left + event.toIndex = cur.right + event.itemCount = content.length + event.text.add(content) + sendAccessibilityEventUnchecked(event) + } + + /** + * Emits a [AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED] event so the screen reader announces + * inserted and deleted characters, like a standard [EditText]. TalkBack reconstructs the + * change from [AccessibilityEvent.getFromIndex], added/removed counts, the current text and + * [AccessibilityEvent.getBeforeText]. Cheap no-op when no accessibility service is running. + */ + @Suppress("DEPRECATION") + fun sendTextChangedAccessibilityEvent(changeEvent: ContentChangeEvent) { + val manager = accessibilityManager ?: return + if (!manager.isEnabled) return + val current = text.toString() + val length = current.length + val fromIndex = changeEvent.changeStart.index.coerceIn(0, length) + val changed = changeEvent.changedText + val changedLen = changed.length + + val event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED) + onInitializeAccessibilityEvent(event) + event.className = EditText::class.java.name + event.fromIndex = fromIndex + when (changeEvent.action) { + ContentChangeEvent.ACTION_INSERT -> { + event.addedCount = changedLen + event.removedCount = 0 + // beforeText = current text with the just-inserted range removed. + val insertEnd = (fromIndex + changedLen).coerceIn(0, length) + event.beforeText = current.substring(0, fromIndex) + current.substring(insertEnd) + } + + ContentChangeEvent.ACTION_DELETE -> { + event.addedCount = 0 + event.removedCount = changedLen + // beforeText = current text with the just-deleted text put back. + event.beforeText = current.substring(0, fromIndex) + changed + current.substring(fromIndex) + } + + else -> { // ACTION_SET_NEW_TEXT and any other wholesale replacement + event.addedCount = length + event.removedCount = 0 + event.beforeText = "" + } + } + event.text.add(current) + sendAccessibilityEventUnchecked(event) + } + // endregion Accessibility + override fun copyTextToClipboard( text: CharSequence, start: Int, @@ -934,6 +1220,9 @@ constructor( return@subscribeEvent } + // Announce inserted/deleted characters to screen readers, like a standard EditText. + sendTextChangedAccessibilityEvent(event) + refreshModifiedState() // A pending inline suggestion is anchored to the pre-edit cursor position; any edit // invalidates it. The plugin re-issues one after its debounce. @@ -952,6 +1241,9 @@ constructor( return@subscribeEvent } + // Keep the screen reader's caret in sync as the selection moves, like an EditText. + sendSelectionChangedAccessibilityEvent() + // Moving the cursor away from the anchor makes ghost text meaningless. dismissInlineSuggestion() diff --git a/editor/src/main/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegments.kt b/editor/src/main/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegments.kt new file mode 100644 index 0000000000..cd4a327305 --- /dev/null +++ b/editor/src/main/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegments.kt @@ -0,0 +1,243 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.editor.utils + +import android.view.accessibility.AccessibilityNodeInfo +import java.text.BreakIterator + +/** + * Pure text-segment iteration used to keep the editor's text cursor in sync with a + * screen reader's "move by granularity" navigation, so the editor behaves like a + * standard [android.widget.EditText] under TalkBack. + * + * The editor exposes its whole content to accessibility services as a single flat + * [CharSequence]. Movement granularity actions therefore operate on absolute character + * indices into that flat text; the caller is responsible for mapping the returned + * indices back to (line, column) positions in the editor. + * + * The character and word implementations mirror the semantics of the framework's + * (hidden) `android.view.AccessibilityIterators`, so navigation matches what users + * already expect from an [android.widget.EditText]. Line navigation walks logical + * lines (separated by `'\n'`), which for source code is the natural unit. + * + * @author Code On The Go + */ +object EditorAccessibilitySegments { + + private const val DONE = BreakIterator.DONE + + /** + * The set of movement granularities that [following] and [preceding] can handle. + * Intended to be advertised via [AccessibilityNodeInfo.setMovementGranularities]. + */ + const val SUPPORTED_GRANULARITIES = + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER or + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD or + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE or + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH + + /** + * Returns the `[start, end]` range (end-exclusive) of the segment that follows + * [offset] for the given [granularity], or `null` when there is nothing after it. + */ + fun following( + text: CharSequence, + granularity: Int, + offset: Int, + ): IntArray? = + when (granularity) { + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER -> characterFollowing(text, offset) + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD -> wordFollowing(text, offset) + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE -> lineFollowing(text, offset) + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH -> paragraphFollowing(text, offset) + else -> null + } + + /** + * Returns the `[start, end]` range (end-exclusive) of the segment that precedes + * [offset] for the given [granularity], or `null` when there is nothing before it. + */ + fun preceding( + text: CharSequence, + granularity: Int, + offset: Int, + ): IntArray? = + when (granularity) { + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER -> characterPreceding(text, offset) + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD -> wordPreceding(text, offset) + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE -> linePreceding(text, offset) + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH -> paragraphPreceding(text, offset) + else -> null + } + + // --------------------------------------------------------------------------------------------- + // Character + // --------------------------------------------------------------------------------------------- + + private fun characterFollowing(text: CharSequence, offset: Int): IntArray? { + val length = text.length + if (length <= 0 || offset >= length) return null + val iterator = BreakIterator.getCharacterInstance().apply { setText(text.toString()) } + var start = if (offset < 0) 0 else offset + if (!iterator.isBoundary(start)) { + start = iterator.following(start) + if (start == DONE) return null + } + val end = iterator.following(start) + if (end == DONE) return null + return intArrayOf(start, end) + } + + private fun characterPreceding(text: CharSequence, offset: Int): IntArray? { + val length = text.length + if (length <= 0 || offset <= 0) return null + val iterator = BreakIterator.getCharacterInstance().apply { setText(text.toString()) } + var end = if (offset > length) length else offset + if (!iterator.isBoundary(end)) { + end = iterator.preceding(end) + if (end == DONE) return null + } + val start = iterator.preceding(end) + if (start == DONE) return null + return intArrayOf(start, end) + } + + // --------------------------------------------------------------------------------------------- + // Word + // --------------------------------------------------------------------------------------------- + + private fun wordFollowing(text: CharSequence, offset: Int): IntArray? { + val length = text.length + if (length <= 0 || offset >= length) return null + val iterator = BreakIterator.getWordInstance().apply { setText(text.toString()) } + var start = if (offset < 0) 0 else offset + // If the offset lands inside a word, snap back to that word's start so the whole + // enclosing word is traversed instead of a truncated tail (e.g. "hello"@2 -> [0,5]). + if (isLetterOrDigit(text, start)) { + while (start > 0 && isLetterOrDigit(text, start - 1)) start-- + } + // Skip past any non-word characters (whitespace, punctuation) to the start of a word. + while (!isLetterOrDigit(text, start) && !isWordStart(text, start)) { + start = iterator.following(start) + if (start == DONE) return null + } + val end = iterator.following(start) + if (end == DONE || !isWordEnd(text, end)) return null + return intArrayOf(start, end) + } + + private fun wordPreceding(text: CharSequence, offset: Int): IntArray? { + val length = text.length + if (length <= 0 || offset <= 0) return null + val iterator = BreakIterator.getWordInstance().apply { setText(text.toString()) } + var end = if (offset > length) length else offset + // If the offset lands inside a word, snap forward to that word's end so the whole + // enclosing word is traversed instead of a truncated head (e.g. "hello"@2 -> [0,5]). + if (isLetterOrDigit(text, end - 1) && isLetterOrDigit(text, end)) { + while (end < length && isLetterOrDigit(text, end)) end++ + } + while (!isLetterOrDigit(text, end - 1) && !isWordEnd(text, end)) { + end = iterator.preceding(end) + if (end == DONE) return null + } + val start = iterator.preceding(end) + if (start == DONE || !isWordStart(text, start)) return null + return intArrayOf(start, end) + } + + private fun isWordStart(text: CharSequence, index: Int): Boolean = + isLetterOrDigit(text, index) && (index == 0 || !isLetterOrDigit(text, index - 1)) + + private fun isWordEnd(text: CharSequence, index: Int): Boolean = + (index > 0 && isLetterOrDigit(text, index - 1)) && + (index == text.length || !isLetterOrDigit(text, index)) + + private fun isLetterOrDigit(text: CharSequence, index: Int): Boolean = + index in 0 until text.length && Character.isLetterOrDigit(text[index]) + + // --------------------------------------------------------------------------------------------- + // Line (logical, '\n'-separated) + // --------------------------------------------------------------------------------------------- + + private fun lineFollowing(text: CharSequence, offset: Int): IntArray? { + val length = text.length + if (length <= 0 || offset >= length) return null + var start = if (offset < 0) 0 else offset + // When not already at a line start, advance to the beginning of the next line. + if (start > 0 && text[start - 1] != '\n') { + while (start < length && text[start] != '\n') start++ + if (start < length) start++ // step over the newline onto the next line + if (start >= length) return null + } + var end = start + while (end < length && text[end] != '\n') end++ + return intArrayOf(start, end) + } + + private fun linePreceding(text: CharSequence, offset: Int): IntArray? { + val length = text.length + if (length <= 0 || offset <= 0) return null + var end = if (offset > length) length else offset + // If sitting on a line start, step back onto the previous line's newline. + if (text[end - 1] == '\n') end-- + if (end <= 0) return null + var start = end + while (start > 0 && text[start - 1] != '\n') start-- + var lineEnd = start + while (lineEnd < length && text[lineEnd] != '\n') lineEnd++ + return intArrayOf(start, lineEnd) + } + + // --------------------------------------------------------------------------------------------- + // Paragraph (non-empty '\n'-separated lines; blank lines are skipped, as EditText does) + // --------------------------------------------------------------------------------------------- + + private fun paragraphFollowing(text: CharSequence, offset: Int): IntArray? { + val length = text.length + if (length <= 0 || offset >= length) return null + var start = if (offset < 0) 0 else offset + // Mirror lineFollowing: when not already at a line start, advance to the next line. + if (start > 0 && text[start - 1] != '\n') { + while (start < length && text[start] != '\n') start++ + if (start < length) start++ // step over the newline onto the next line + if (start >= length) return null + } + // Skip blank lines so navigation lands on the next non-empty line. + while (start < length && text[start] == '\n') start++ + if (start >= length) return null + var end = start + while (end < length && text[end] != '\n') end++ + return intArrayOf(start, end) + } + + private fun paragraphPreceding(text: CharSequence, offset: Int): IntArray? { + val length = text.length + if (length <= 0 || offset <= 0) return null + var end = if (offset > length) length else offset + // Skip blank lines (trailing newlines) so we land on the previous non-empty line. + while (end > 0 && text[end - 1] == '\n') end-- + if (end <= 0) return null + var start = end + while (start > 0 && text[start - 1] != '\n') start-- + // Mirror linePreceding: scan forward to the line end so a mid-paragraph offset returns + // the whole line instead of a truncated head. + var lineEnd = start + while (lineEnd < length && text[lineEnd] != '\n') lineEnd++ + return intArrayOf(start, lineEnd) + } +} diff --git a/editor/src/test/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegmentsTest.kt b/editor/src/test/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegmentsTest.kt new file mode 100644 index 0000000000..c3163e7f22 --- /dev/null +++ b/editor/src/test/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegmentsTest.kt @@ -0,0 +1,232 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.editor.utils + +import android.view.accessibility.AccessibilityNodeInfo +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for [EditorAccessibilitySegments], which drives screen-reader cursor + * synchronization. The returned ranges are absolute char indices into the flat text. + */ +@RunWith(RobolectricTestRunner::class) +class EditorAccessibilitySegmentsTest { + + private val char = AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER + private val word = AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD + private val line = AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE + private val paragraph = AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH + + private fun following(text: String, granularity: Int, offset: Int) = + EditorAccessibilitySegments.following(text, granularity, offset)?.toList() + + private fun preceding(text: String, granularity: Int, offset: Int) = + EditorAccessibilitySegments.preceding(text, granularity, offset)?.toList() + + // --------------------------------------------------------------------------------------------- + // Character + // --------------------------------------------------------------------------------------------- + + @Test + fun `character following returns the next single character`() { + assertThat(following("abc", char, 0)).isEqualTo(listOf(0, 1)) + assertThat(following("abc", char, 1)).isEqualTo(listOf(1, 2)) + } + + @Test + fun `character following at end returns null`() { + assertThat(following("abc", char, 3)).isNull() + } + + @Test + fun `character preceding returns the previous single character`() { + assertThat(preceding("abc", char, 3)).isEqualTo(listOf(2, 3)) + assertThat(preceding("abc", char, 1)).isEqualTo(listOf(0, 1)) + } + + @Test + fun `character preceding at start returns null`() { + assertThat(preceding("abc", char, 0)).isNull() + } + + // --------------------------------------------------------------------------------------------- + // Word + // --------------------------------------------------------------------------------------------- + + @Test + fun `word following skips whitespace and returns whole words`() { + val text = "foo bar" + assertThat(following(text, word, 0)).isEqualTo(listOf(0, 3)) // foo + assertThat(following(text, word, 3)).isEqualTo(listOf(4, 7)) // bar + } + + @Test + fun `word following past last word returns null`() { + assertThat(following("foo bar", word, 7)).isNull() + } + + @Test + fun `word following from inside a word snaps to the whole enclosing word`() { + // Offset 2 is inside "hello"; the whole word must be traversed, not a truncated tail. + assertThat(following("hello", word, 2)).isEqualTo(listOf(0, 5)) + } + + @Test + fun `word preceding from inside a word snaps to the whole enclosing word`() { + assertThat(preceding("hello", word, 2)).isEqualTo(listOf(0, 5)) + } + + @Test + fun `word preceding returns previous word`() { + val text = "foo bar" + assertThat(preceding(text, word, 7)).isEqualTo(listOf(4, 7)) // bar + assertThat(preceding(text, word, 4)).isEqualTo(listOf(0, 3)) // foo + } + + @Test + fun `word navigation skips separator punctuation between identifiers`() { + // Simulates code like "x=y" — the identifiers are words, '=' is skipped. + val text = "x=y" + assertThat(following(text, word, 0)).isEqualTo(listOf(0, 1)) // x + assertThat(following(text, word, 1)).isEqualTo(listOf(2, 3)) // y + } + + @Test + fun `word navigation treats a dotted identifier as a single word`() { + // Matches EditText/BreakIterator semantics: "a.b" is one word, not three. + val text = "a.b" + assertThat(following(text, word, 0)).isEqualTo(listOf(0, 3)) + } + + // --------------------------------------------------------------------------------------------- + // Line + // --------------------------------------------------------------------------------------------- + + @Test + fun `line following returns the current line when at its start`() { + val text = "abc\ndef\nghi" + assertThat(following(text, line, 0)).isEqualTo(listOf(0, 3)) // abc + } + + @Test + fun `line following from mid-line advances to the next line`() { + val text = "abc\ndef\nghi" + // offset 5 is inside "def"; next line is "ghi". + assertThat(following(text, line, 5)).isEqualTo(listOf(8, 11)) + } + + @Test + fun `line following from end of a line moves to the next line`() { + val text = "abc\ndef" + // offset 3 is the '\n' after abc -> caret sits at end of line 0. + assertThat(following(text, line, 3)).isEqualTo(listOf(4, 7)) // def + } + + @Test + fun `line preceding returns the line the caret sits at the end of`() { + val text = "abc\ndef" + assertThat(preceding(text, line, 7)).isEqualTo(listOf(4, 7)) // def + } + + @Test + fun `line preceding from a line start moves to the previous line`() { + val text = "abc\ndef" + // offset 4 is the start of "def"; previous line is "abc". + assertThat(preceding(text, line, 4)).isEqualTo(listOf(0, 3)) + } + + @Test + fun `line handles empty lines`() { + val text = "abc\n\ndef" + // index 4 is the empty line between the two newlines. + assertThat(following(text, line, 4)).isEqualTo(listOf(4, 4)) + } + + // --------------------------------------------------------------------------------------------- + // Paragraph + // --------------------------------------------------------------------------------------------- + + @Test + fun `paragraph following returns the current non-empty line`() { + val text = "abc\ndef\nghi" + assertThat(following(text, paragraph, 0)).isEqualTo(listOf(0, 3)) // abc + } + + @Test + fun `paragraph following skips blank lines`() { + val text = "abc\n\ndef" + // From end of "abc" (index 3), the next paragraph is "def" — the blank line is skipped. + assertThat(following(text, paragraph, 3)).isEqualTo(listOf(5, 8)) + } + + @Test + fun `paragraph preceding skips blank lines`() { + val text = "abc\n\ndef" + // From the start of "def" (index 5), the previous paragraph is "abc". + assertThat(preceding(text, paragraph, 5)).isEqualTo(listOf(0, 3)) + } + + @Test + fun `paragraph preceding returns the line the caret sits at the end of`() { + val text = "abc\ndef" + assertThat(preceding(text, paragraph, 7)).isEqualTo(listOf(4, 7)) // def + } + + @Test + fun `paragraph following from a mid-paragraph offset advances to the next paragraph`() { + val text = "abc\ndef\nghi" + // offset 5 is inside "def"; next paragraph is "ghi". + assertThat(following(text, paragraph, 5)).isEqualTo(listOf(8, 11)) + } + + @Test + fun `paragraph preceding from a mid-paragraph offset returns the whole enclosing paragraph`() { + val text = "abc\ndef\nghi" + // offset 5 is inside "def"; the whole line must be returned, not a truncated head. + assertThat(preceding(text, paragraph, 5)).isEqualTo(listOf(4, 7)) + } + + // --------------------------------------------------------------------------------------------- + // Guards + // --------------------------------------------------------------------------------------------- + + @Test + fun `empty text yields no segments`() { + assertThat(following("", char, 0)).isNull() + assertThat(preceding("", word, 0)).isNull() + assertThat(following("", line, 0)).isNull() + } + + @Test + fun `unsupported granularity yields null`() { + assertThat(following("abc", 999, 0)).isNull() + assertThat(preceding("abc", 999, 3)).isNull() + } + + @Test + fun `supported granularities bitmask includes character word and line`() { + val mask = EditorAccessibilitySegments.SUPPORTED_GRANULARITIES + assertThat(mask and char).isEqualTo(char) + assertThat(mask and word).isEqualTo(word) + assertThat(mask and line).isEqualTo(line) + assertThat(mask and paragraph).isEqualTo(paragraph) + } +}