Skip to content

fix(editor): sync TalkBack accessibility focus with the text cursor#1509

Open
makhlwf wants to merge 2 commits into
appdevforall:stagefrom
makhlwf:adfa-editor-a11y-cursor-sync
Open

fix(editor): sync TalkBack accessibility focus with the text cursor#1509
makhlwf wants to merge 2 commits into
appdevforall:stagefrom
makhlwf:adfa-editor-a11y-cursor-sync

Conversation

@makhlwf

@makhlwf makhlwf commented Jul 11, 2026

Copy link
Copy Markdown

Problem

The code editor is very hard to use with TalkBack. Double-tapping a word/line fails to sync the text cursor with the accessibility focus — the caret disappears or lands in the wrong place, and typing is inserted on the wrong line (e.g. line 8 instead of line 3). Users are forced into the screen reader's native edit box as a workaround.

Root cause: the editor wraps Rosemoe's CodeEditor, a custom-drawn View. Its accessibility node reports the whole document as a single generic editable node (class name CodeEditor) and advertises no text-movement granularities, ACTION_SET_SELECTION, or granularity actions. So TalkBack has no way to move the real text caret in step with the reading position; a double-tap sends ACTION_CLICK to the node centre.

Fix

Make IDEEditor behave like a standard EditText for screen readers:

  • Reports as android.widget.EditText and advertises CHARACTER / WORD / LINE movement granularities plus ACTION_SET_SELECTION and the next/previous-at-granularity actions on the node.
  • Handles those actions by converting absolute character indices ↔ (line, column) via the editor's Indexer and moving the real caret (setSelection/setSelectionRegion), then emits TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY so the spoken segment and the caret stay in sync.
  • Double-tap placement: tracks the last touch-exploration hover point (dispatchHoverEvent) and, on ACTION_CLICK, places the caret at that on-screen point via getPointPositionOnScreen. Gated on touch-exploration being enabled, so normal (sighted) use is unaffected.

Text-segment iteration lives in the new EditorAccessibilitySegments (character and word mirror the framework's AccessibilityIterators; line walks logical \n-separated lines).

Testing

  • New EditorAccessibilitySegmentsTest (17 tests, Robolectric + Truth) covering following/preceding for character, word and line, plus empty-text / boundary / unsupported-granularity guards. Green via :editor:testV8DebugUnitTest.
  • :editor module compiles clean (no new warnings).

Not covered here

On-device TalkBack behaviour (double-tap placement + granular navigation on real hardware) was not verified in CI — the wiring follows the documented Android EditText accessibility contract and is unit-tested, but a manual TalkBack pass is recommended before release.

The editor wraps Rosemoe's CodeEditor, a custom-drawn View. Its
accessibility node reported the whole document as a single generic
editable node (class name CodeEditor) and advertised no text-movement
granularities, ACTION_SET_SELECTION or granularity actions. TalkBack
therefore had no way to move the real text caret with the reading
position: double-tapping a word/line placed the caret at the node's
centre and subsequent typing landed on the wrong line.

Make IDEEditor behave like a standard EditText for screen readers:

- Report as android.widget.EditText and advertise CHARACTER/WORD/LINE
  movement granularities plus ACTION_SET_SELECTION and the
  next/previous-at-granularity actions.
- Handle those actions by converting absolute char indices to
  (line, column) via the editor's Indexer and moving the real caret,
  then emit TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY so the
  spoken segment and caret stay in sync.
- Track the last touch-exploration hover point and, on ACTION_CLICK,
  place the caret there so a double-tap lands on the explored word/line
  (only active while touch exploration is on, so sighted use is
  unaffected).

Text-segment iteration lives in EditorAccessibilitySegments (character
and word mirror the framework's AccessibilityIterators; line walks
logical '\n'-separated lines) and is covered by unit tests.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@makhlwf

makhlwf commented Jul 11, 2026

Copy link
Copy Markdown
Author

Can someone please build it for me so I can test it? I don't think there is a GitHub action to build it. I followed the documentation, but I need a device to be able to build it, and I don't think I can get one.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1a89f6c6-7863-4457-92d3-85e7fca9fc05

📥 Commits

Reviewing files that changed from the base of the PR and between 7ca9295 and e88685a.

📒 Files selected for processing (3)
  • editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt
  • editor/src/main/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegments.kt
  • editor/src/test/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegmentsTest.kt
🚧 Files skipped from review as they are similar to previous changes (3)
  • editor/src/main/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegments.kt
  • editor/src/test/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegmentsTest.kt
  • editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt

📝 Walkthrough
  • Improved IDEEditor accessibility for TalkBack by exposing EditText-like semantics (only when the editor is enabled/editable and not released) and synchronizing accessibility focus with the text cursor.
  • Added accessibility cursor synchronization: track the last touch-exploration hover point and move the caret there on ACTION_CLICK when touch exploration is enabled.
  • Advertised and implemented accessibility actions for selection and granular navigation: ACTION_SET_SELECTION, character/word/line movement granularities (with optional selection extension), and traversal events (TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY) after moves.
  • Implemented EditorAccessibilitySegments to compute next/previous character, word, and logical line “segments” (via BreakIterator with clamping/boundary handling, snapping offsets inside words, dotted identifiers treated as single words, and empty/unsupported granularity returning null).
  • Added unit tests for navigation correctness, boundaries, empty text, unsupported granularities, and mid-word offset behavior (including dotted identifiers).
  • Best-practice improvement: narrowed accessibility exception handling to avoid overly broad catching (IndexOutOfBoundsException / IllegalArgumentException) and added guard/regression coverage.
  • Risk: on-device TalkBack behavior was not verified in CI.
  • Risk: custom word/line segmentation behavior may diverge from platform screen-reader expectations in rare edge cases.

Walkthrough

IDEEditor now exposes EditText-style accessibility semantics, supports TalkBack selection and movement actions, synchronizes caret placement from touch exploration, and announces traversed text using new character, word, and line segment calculations.

Changes

Accessibility cursor support

Layer / File(s) Summary
Accessibility segment engine
editor/src/main/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegments.kt, editor/src/test/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegmentsTest.kt
Adds character, word, and logical-line segment navigation with boundary, empty-input, unsupported-granularity, and movement tests.
Accessibility node semantics
editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt
Advertises EditText semantics and exposes movement granularities and selection actions for active editable content.
Accessibility cursor actions
editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt
Handles hover, click, selection, and granularity actions, maps indices to editor positions, and emits traversed-text events.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TalkBack
  participant IDEEditor
  participant EditorAccessibilitySegments
  participant ContentIndexer
  TalkBack->>IDEEditor: Request movement action
  IDEEditor->>EditorAccessibilitySegments: Calculate next or previous segment
  EditorAccessibilitySegments-->>IDEEditor: Return segment indices
  IDEEditor->>ContentIndexer: Convert indices to line and column
  ContentIndexer-->>IDEEditor: Return editor positions
  IDEEditor-->>TalkBack: Update selection and announce traversed text
Loading

Poem

A bunny taps the editor bright,
And hops through words from left to right.
Lines and letters join the race,
The caret finds its proper place.
“Thump!” says the screen reader light.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: syncing TalkBack accessibility focus with the text cursor in the editor.
Description check ✅ Passed The description is directly related to the changeset and accurately describes the accessibility fixes and tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch adfa-editor-a11y-cursor-sync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt (2)

701-726: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

sendTextTraversedEvent copies the entire document on every granularity move.

event.text.add(text.toString()) (line 724) makes a full string copy of the document each time, on top of the copies already made inside EditorAccessibilitySegments. This only runs while an accessibility service is enabled, so impact is limited to that population, but for large files it compounds the per-navigation cost flagged in EditorAccessibilitySegments.kt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt` around
lines 701 - 726, The sendTextTraversedEvent method copies the entire document
for every accessibility navigation event. Remove the
event.text.add(text.toString()) operation and preserve the event metadata and
dispatch flow without creating a full-document string copy.

612-615: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer narrower exception types over broad catch (e: Exception).

Based on learnings, this project prefers catching only the specific exception types expected from the call (e.g. IndexOutOfBoundsException/IllegalArgumentException from setSelection/setSelectionRegion/getCharPosition) rather than a catch-all, to fail fast during development and surface unexpected errors instead of silently swallowing them.

Also applies to: 695-698

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt` around
lines 612 - 615, Replace the broad catch blocks around the accessibility
cursor-placement logic in IDEEditor with only the expected
IndexOutOfBoundsException and IllegalArgumentException types from setSelection,
setSelectionRegion, or getCharPosition; preserve the existing logging and
false-return behavior for those cases while allowing unexpected exceptions to
propagate.

Source: Learnings

editor/src/test/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegmentsTest.kt (1)

73-105: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a mid-word offset case for WORD granularity.

All current word tests use offsets that already sit at word/whitespace boundaries. Adding a case like following("hello", word, 2) would have caught the mid-word truncation bug flagged in EditorAccessibilitySegments.kt (word skip-loop doesn't snap back to the enclosing word's start when the offset is already inside a word).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@editor/src/test/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegmentsTest.kt`
around lines 73 - 105, The WORD navigation tests lack coverage for offsets
inside an existing word. Add a mid-word case in the `word following` tests, such
as calling `following("hello", word, 2)` and asserting the complete word range
`[0, 5]`, so `EditorAccessibilitySegments` preserves the enclosing word instead
of truncating it.
editor/src/main/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegments.kt (1)

92-92: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Fresh BreakIterator + full text.toString() copy on every character/word move.

Each call to characterFollowing/characterPreceding/wordFollowing/wordPreceding allocates a new BreakIterator and copies the entire document via text.toString(). Since these are invoked on every single TalkBack character/word navigation action, this is an O(n) allocation per keystroke-equivalent gesture — noticeable on large source files. Consider caching the last (text, BreakIterator) pair (invalidate on text-identity change) or backing the iterator with a CharacterIterator adapter over the CharSequence instead of copying to String.

Also applies to: 106-106, 124-124, 139-139

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@editor/src/main/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegments.kt`
at line 92, Update the character and word navigation methods around the
BreakIterator usages to avoid creating a new iterator and copying the entire
CharSequence on every call. Cache and reuse the BreakIterator with the last text
identity, invalidating it when the source text changes, or use a
CharacterIterator adapter backed directly by the CharSequence; apply the same
optimization to characterFollowing, characterPreceding, wordFollowing, and
wordPreceding.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt`:
- Around line 550-564: Update IDEEditor’s getAccessibilityClassName() to return
the EditText class name only when !isReleased, isEnabled, and isEditable,
matching createAccessibilityNodeInfo(). For all other states, return the
superclass accessibility class name so read-only or released editors do not
expose edit-box semantics in accessibility events.

In
`@editor/src/main/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegments.kt`:
- Around line 121-148: Update wordFollowing and wordPreceding to first snap
mid-word offsets to the enclosing word’s start or end boundary, so navigation
never returns a truncated range such as [2, 5] or [0, 2] for “hello”. Preserve
existing scanning for whitespace and punctuation, and add a regression test in
EditorAccessibilitySegmentsTest.kt covering mid-word offsets.

---

Nitpick comments:
In `@editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt`:
- Around line 701-726: The sendTextTraversedEvent method copies the entire
document for every accessibility navigation event. Remove the
event.text.add(text.toString()) operation and preserve the event metadata and
dispatch flow without creating a full-document string copy.
- Around line 612-615: Replace the broad catch blocks around the accessibility
cursor-placement logic in IDEEditor with only the expected
IndexOutOfBoundsException and IllegalArgumentException types from setSelection,
setSelectionRegion, or getCharPosition; preserve the existing logging and
false-return behavior for those cases while allowing unexpected exceptions to
propagate.

In
`@editor/src/main/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegments.kt`:
- Line 92: Update the character and word navigation methods around the
BreakIterator usages to avoid creating a new iterator and copying the entire
CharSequence on every call. Cache and reuse the BreakIterator with the last text
identity, invalidating it when the source text changes, or use a
CharacterIterator adapter backed directly by the CharSequence; apply the same
optimization to characterFollowing, characterPreceding, wordFollowing, and
wordPreceding.

In
`@editor/src/test/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegmentsTest.kt`:
- Around line 73-105: The WORD navigation tests lack coverage for offsets inside
an existing word. Add a mid-word case in the `word following` tests, such as
calling `following("hello", word, 2)` and asserting the complete word range `[0,
5]`, so `EditorAccessibilitySegments` preserves the enclosing word instead of
truncating it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b2210ce8-0e06-4975-846d-db11ef1fa261

📥 Commits

Reviewing files that changed from the base of the PR and between 232cec2 and 76246e8.

📒 Files selected for processing (3)
  • editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt
  • editor/src/main/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegments.kt
  • editor/src/test/java/com/itsaky/androidide/editor/utils/EditorAccessibilitySegmentsTest.kt

Comment thread editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt Outdated
- getAccessibilityClassName() now mirrors the !isReleased && isEnabled &&
  isEditable guard used by createAccessibilityNodeInfo(), so a read-only or
  released editor is no longer announced as an "Edit box".
- Word granularity navigation snaps a mid-word offset to the enclosing word's
  start/end, so navigation traverses the whole word (e.g. "hello"@2 -> [0,5])
  instead of a truncated range. Adds regression tests for mid-word offsets.
- Narrow the accessibility catch blocks to IndexOutOfBoundsException /
  IllegalArgumentException instead of a broad Exception, per project convention.
@makhlwf

makhlwf commented Jul 11, 2026

Copy link
Copy Markdown
Author

Thanks for the review @coderabbitai. Addressed in 7ca9295f9. Summary of each point:

Major (both fixed)

  • getAccessibilityClassName() now mirrors the !isReleased && isEnabled && isEditable guard.
  • ✅ Word navigation snaps mid-word offsets to the enclosing word + regression tests added.

Nitpicks

  • Narrower exception types — narrowed the two accessibility catch blocks (handleAccessibilityClick, applySelectionFromIndices) to IndexOutOfBoundsException / IllegalArgumentException.
  • Mid-word WORD test — added following("hello", word, 2) / preceding(...) asserting [0, 5].
  • ⏭️ sendTextTraversedEvent copies the whole documentkeeping as-is by design. For TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY, the screen reader substrings [fromIndex, toIndex] out of event.getText(), so the indices must address the full document text — the same thing TextView does, and the same full-text payload the base CodeEditor.createAccessibilityNodeInfo() already sends via info.setText(...). Dropping it would break announcement of the traversed segment. It only runs while an accessibility service is enabled and once per navigation gesture (not per keystroke).
  • ⏭️ Fresh BreakIterator + toString() per movedeferring. Same gating (a11y-only, per-gesture). A cached-iterator-with-text-identity-invalidation adds a correctness-sensitive cache to a rarely-hot path; I'd rather not introduce it without on-device profiling showing it matters. Happy to file a follow-up if you'd like it tracked.

:editor:testV8DebugUnitTest is green (19 tests). Note: on-device TalkBack behaviour still needs a manual pass, as called out in the PR description.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@makhlwf Thanks for the detailed follow-up and for pushing the fixes in 7ca9295f9.

  • The getAccessibilityClassName() guard alignment and the word-navigation snapping fix (with regression tests) both address the major concerns well.
  • Narrowing the catch blocks to IndexOutOfBoundsException/IllegalArgumentException and adding the mid-word WORD test cover the nitpicks cleanly.

On the two deferred items, your reasoning holds up:

  • For sendTextTraversedEvent, since TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY consumers substring event.getText() using [fromIndex, toIndex], the indices do need to address the full document text — that's consistent with how TextView and the base CodeEditor.createAccessibilityNodeInfo() already behave, and it's gated to accessibility-enabled sessions, so I agree keeping it as-is is fine.
  • Deferring the BreakIterator/toString() caching without on-device profiling data is a reasonable call given it's a rarely-hot, per-gesture path rather than per-keystroke. Happy to have this tracked as a follow-up if you'd like — just let me know and I can open an issue referencing this PR and comment.

Nice work getting :editor:testV8DebugUnitTest green across 19 tests. Once the on-device TalkBack pass is done, this should be in good shape.

🐰✨

@makhlwf makhlwf force-pushed the adfa-editor-a11y-cursor-sync branch from 7ca9295 to e88685a Compare July 11, 2026 22:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant