Add right-to-left (RTL) text support#213
Conversation
|
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 |
31b5b8c to
ce8eb8c
Compare
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]>
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]>
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]>
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
|
Update — un-drafted, ready for review again. The The same fix is upstreamed at flutter/flutter#188719 (now approved by a Flutter maintainer). Because |
Adds right-to-left (RTL) text support for Farsi, Arabic, and Hebrew. Closes #174.
What it does
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.??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 overintl'sBidi.startsWithRtl, which misclassifies emoji-leading text as LTR (the leading surrogate falls in its LTR ranges).textDirectionof each messageRichText/Text.richand the conversation tile;getNotificationTextwraps embedded text in a first-strong isolate (U+2068…U+2069).TextFieldis wrapped inTextDirectionBuilder(direction-flip-gated rebuild).SpellCheckTextEditingController.set valuesnaps a collapsed caret (or selection endpoint) off any UTF-16 surrogate-pair interior —snapSelectionOffSurrogatePairs()inlib/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 crashParagraphBuilderon 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
EditableTextforwards 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.dart—getTextDirectionover 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_testdisabled; the tests live here).