Skip to content

Add right-to-left (RTL) text support#213

Open
kfatehi wants to merge 3 commits into
OpenBubbles:rustpushfrom
kfatehi:openbubbles-rtl
Open

Add right-to-left (RTL) text support#213
kfatehi wants to merge 3 commits into
OpenBubbles:rustpushfrom
kfatehi:openbubbles-rtl

Conversation

@kfatehi

@kfatehi kfatehi commented Jun 9, 2026

Copy link
Copy Markdown

Adds right-to-left (RTL) text support for Farsi, Arabic, and Hebrew. Closes #174.

What it does

  • Rendering: message bubbles, reply bubbles/previews, conversation-list titles/subtitles, and the send-animation bubble render with the correct paragraph direction (RTL text aligns right; trailing punctuation/emoji land correctly). Reaction/reply/notification text embedding an RTL message is wrapped in a Unicode first-strong isolate so it orders correctly in the surrounding sentence.
  • Input: the compose and subject fields go RTL (right-aligned, RTL base direction) as you type RTL text, via TextDirectionBuilder — which recomputes direction live but rebuilds the field only when the first-strong direction actually flips, not on every keystroke/selection change, so caret dragging works normally.
  • Emoji-corruption fix: prevents the ?? surrogate-pair corruption that RTL input could trigger (see "Emoji corruption" below).

How

  • getTextDirection() (lib/helpers/ui/text_direction_helpers.dart): UAX#9 "first strong character" detection over runes — chosen over intl's Bidi.startsWithRtl, which misclassifies emoji-leading text as LTR (the leading surrogate falls in its LTR ranges).
  • Rendering: applied as the textDirection of each message RichText/Text.rich and the conversation tile; getNotificationText wraps embedded text in a first-strong isolate (U+2068U+2069).
  • Input: each compose TextField is wrapped in TextDirectionBuilder (direction-flip-gated rebuild).
  • Caret clamp: SpellCheckTextEditingController.set value snaps a collapsed caret (or selection endpoint) off any UTF-16 surrogate-pair interior — snapSelectionOffSurrogatePairs() in lib/helpers/ui/grapheme_caret.dart — before committing the value, so a subsequent edit can never split an emoji. The caret snaps to after the cluster (the trailing edge), so when a tap lands inside a trailing emoji's glyph the caret ends up past it and a backspace deletes the emoji — in RTL, snapping to the leading edge would instead strand the caret before the emoji and delete the adjacent space.

Emoji corruption — fixed (this was the draft blocker)

Earlier this PR carried an intermittent ?? corruption while composing. With an RTL base direction, tapping on an emoji glyph can land the caret inside the emoji's UTF-16 surrogate pair; the next keystroke then splits the pair into two lone surrogates, which the Android text-input channel encodes as ? (giving ??) and which also crash ParagraphBuilder on paint.

This is a pre-existing Flutter framework bug, not something this PR's RTL approach does wrong: Flutter allows a caret to sit inside a surrogate pair and forwards that offset to the platform IME. RTL input is only the trigger — an LTR tap happens to snap the caret to the grapheme boundary, whereas an RTL tap can land it mid-pair; the standalone repro reproduces the defect with plain Latin text and no RTL at all. It's filed with a deterministic, device-free repro and fixed upstream:

This PR fixes it in-app, with no Flutter upgrade required: because EditableText forwards the controller's value to the IME, clamping the caret in our own compose controller is equivalent to the framework fix, and it's scoped to the compose field only. Verified on-device — Farsi + emoji + tap-on-the-emoji + typing no longer corrupts.

Tests

  • test/rtl_detection_test.dartgetTextDirection over Farsi, emoji/punctuation/digit-leading Farsi (first-strong), English, empty/null, and mixed first-strong.
  • test/grapheme_caret_test.dart — the caret clamp: a caret inside a surrogate pair snaps to the pair boundary, boundary carets are untouched, and inserting at the snapped caret leaves the emoji intact.

Note on mixed-direction text

Mixed LTR/RTL/emoji runs are laid out by Flutter's standard Unicode Bidi Algorithm — the same reordering iMessage and the platform keyboards apply for a given base direction. Intentional, not a rendering bug.

Mirrored in the BlueBubbles PR: BlueBubblesApp#3049 (kept source-only there, since that base has flutter_test disabled; the tests live here).

@kfatehi kfatehi changed the title Add RTL (Right-to-Left) text support for Persian, Arabic, and Hebrew languages Add right-to-left (RTL) text support Jun 9, 2026
@kfatehi kfatehi marked this pull request as draft June 9, 2026 19:30
@kfatehi kfatehi force-pushed the openbubbles-rtl branch from e4fbe8e to 9f64c60 Compare June 9, 2026 23:51
@kfatehi kfatehi marked this pull request as ready for review June 10, 2026 00:08
@kfatehi kfatehi marked this pull request as draft June 24, 2026 19:01
@kfatehi

kfatehi commented Jun 24, 2026

Copy link
Copy Markdown
Author

Drafted again due to discovery of some bugs, will be tracking in the BlueBubbles ticket BlueBubblesApp#3049 and then backport the fixes here once I have them

@kfatehi kfatehi marked this pull request as ready for review June 25, 2026 02:57
@kfatehi kfatehi marked this pull request as draft June 26, 2026 09:01
@kfatehi kfatehi force-pushed the openbubbles-rtl branch 2 times, most recently from 31b5b8c to ce8eb8c Compare June 28, 2026 22:45
@kfatehi kfatehi changed the title Add right-to-left (RTL) text support Add right-to-left (RTL) text support for message rendering Jun 29, 2026
@kfatehi kfatehi marked this pull request as ready for review June 29, 2026 01:51
@kfatehi kfatehi marked this pull request as draft June 29, 2026 02:01
Rendering: message bubbles, reply bubbles/previews, conversation tiles,
the send-animation bubble, and embedded notification/reaction text render
with the correct paragraph direction. Direction via getTextDirection()
(UAX#9 first-strong over runes); embedded text uses a first-strong isolate.

Input: the compose and subject fields go RTL via TextDirectionBuilder,
which rebuilds only when the first-strong direction flips (so caret
dragging works). No per-keystroke direction-forcing, no grapheme-repair.

Known issue (kept draft): intermittent emoji "??" corruption while
composing, not yet reliably reproduced.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@kfatehi kfatehi changed the title Add right-to-left (RTL) text support for message rendering Add right-to-left (RTL) text support Jun 29, 2026
Snap a collapsed caret (or selection endpoint) off any UTF-16 surrogate-pair
interior in the compose controller (SpellCheckTextEditingController.set value)
before committing the value, so a subsequent edit can't split an emoji into
lone surrogates -- which the Android text-input channel encodes as '?' (the
'??' corruption) and which crash ParagraphBuilder on paint.

App-side equivalent of the framework fix in flutter/flutter#188713 (PR
flutter/flutter#188719); needs no Flutter upgrade and is scoped to the compose
field. Adds test/grapheme_caret_test.dart.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
kfatehi added a commit to kfatehi/bluebubbles-app that referenced this pull request Jun 29, 2026
Snap a collapsed caret (or selection endpoint) off any UTF-16 surrogate-pair
interior in the compose controller (SpellCheckTextEditingController.set value,
both exit paths) before committing the value, so a subsequent edit can't split
an emoji into lone surrogates -- which the Android text-input channel encodes
as '?' (the '??' corruption) and which crash ParagraphBuilder on paint.

App-side equivalent of the framework fix in flutter/flutter#188713 (PR
flutter/flutter#188719); needs no Flutter upgrade and is scoped to the compose
field. Source-only per this branch's convention; logic unit-tested in the
OpenBubbles port (OpenBubbles#213).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@kfatehi kfatehi marked this pull request as ready for review June 29, 2026 08:34
@kfatehi kfatehi marked this pull request as draft June 29, 2026 09:08
The compose-field caret clamp snapped a caret that lands inside an emoji's
surrogate pair to the pair start (before the emoji). In RTL a tap aiming for
the spot after a trailing emoji lands mid-glyph and got yanked before it, so
backspace deleted the adjacent space instead of the emoji and the emoji could
not be removed.

Snap to the pair end (offset + 1) instead, so the caret lands after the emoji
and backspace deletes the whole emoji. It is still a boundary, so the next edit
cannot split the pair and the "??" corruption stays fixed. Verified on-device
(Android / Gboard, forced-RTL).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FrEin3bnyFrXaxSmL9iLQM
@kfatehi kfatehi marked this pull request as ready for review June 29, 2026 10:36
@kfatehi

kfatehi commented Jun 29, 2026

Copy link
Copy Markdown
Author

Update — un-drafted, ready for review again.

The ?? emoji corruption that made me re-draft is fixed: the compose controller now clamps the caret out of any UTF-16 surrogate-pair interior, so an edit can't split an emoji into lone surrogates. Tracking it down also surfaced a related RTL annoyance — with a trailing emoji the caret landed on the wrong side, so backspace deleted the preceding space instead of the emoji; it now snaps past the emoji so backspace removes it. Both verified on-device (Farsi + Gboard).

The same fix is upstreamed at flutter/flutter#188719 (now approved by a Flutter maintainer). Because EditableText forwards the controller's value to the IME, the in-app clamp is equivalent and works on the current Flutter pin with no SDK bump — so this PR carries the corruption fix itself. Full details in the updated description.

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.

Add RTL (Right-to-Left) text support for Persian, Arabic, and Hebrew languages

1 participant