Skip to content

Commit 8361ec1

Browse files
kfatehiclaude
andcommitted
Add RTL text support, ported onto the post-rewrite development branch
Reimplements PR BlueBubblesApp#3049's right-to-left support (Farsi/Arabic/Hebrew) on top of `development` instead of the old master-based branch, per jjoelj's note. - getTextDirection(): first-strong (UAX#9) direction detection over runes (lib/helpers/ui/text_direction_helpers.dart); applied as the textDirection of message bubbles, reply bubbles, conversation-list title/subtitle, and the send animation (no LTR flash on send). - Compose + subject TextFields wrapped in TextDirectionBuilder so they flip direction live but rebuild only on an actual first-strong flip (fixes the cursor-drag-cancel bug). - getNotificationText() wraps embedded message text in a Unicode First-Strong Isolate (U+2068..U+2069) so quoted RTL renders correctly inside LTR. - Grapheme-cluster-safe editing (lib/helpers/ui/grapheme_text_helpers.dart): emoji-picker insert and the controller value-setter snap to cluster boundaries and repair IME-split surrogate pairs (the "??" corruption); a repaired edit also clears the now-stale composing region (the cursor-gap bug). - Re-enables flutter_test; adds test/rtl_detection_test.dart and test/grapheme_text_helpers_test.dart (detection, boundary snapping, insert/replace fuzz, IME-split recovery, composing-clear). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YPSpEnnJze96m53nX9k142
1 parent 92de402 commit 8361ec1

15 files changed

Lines changed: 749 additions & 202 deletions

‎lib/app/components/custom_text_editing_controllers.dart‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,18 @@ class SpellCheckTextEditingController extends TextEditingController {
9696
return;
9797
}
9898

99+
// Repair an edit that split a grapheme cluster (e.g. the IME inserted between
100+
// an emoji's surrogate halves while typing in bidi text, which renders "?"),
101+
// before any further processing. A repair also clears the now-stale IME
102+
// composing region (otherwise a phantom gap appears between caret and text).
103+
final repairedValue = repairGraphemeSplitValue(text, newValue);
104+
final bool didRepair = repairedValue != null;
105+
if (didRepair) {
106+
newValue = repairedValue;
107+
newText = newValue.text;
108+
newOffset = newValue.selection.start;
109+
}
110+
99111
if (SettingsSvc.settings.replaceEmoticonsWithEmoji.value) {
100112
List<(int, int)> offsetsAndDifferences;
101113
(newText, offsetsAndDifferences) = replaceEmoticons(newText);
@@ -128,6 +140,9 @@ class SpellCheckTextEditingController extends TextEditingController {
128140
newValue = newValue.copyWith(
129141
text: newText,
130142
selection: TextSelection.collapsed(offset: newOffset),
143+
// A repaired edit invalidates the IME's composing region (it points into
144+
// the pre-repair coordinates), so clear it.
145+
composing: didRepair ? TextRange.empty : newValue.composing,
131146
);
132147
}
133148

‎lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ class _ChatTitleState extends CustomState<ChatTitle, void, ConversationTileContr
191191
final _title = controller.chatState.title.value ?? controller.chat.getTitle();
192192

193193
return RichText(
194+
textDirection: getTextDirection(_title),
194195
text: TextSpan(
195196
children: MessageHelper.buildEmojiText(
196197
_title,
@@ -274,6 +275,7 @@ class _ChatSubtitleState extends CustomState<ChatSubtitle, void, ConversationTil
274275
}
275276

276277
final richText = RichText(
278+
textDirection: getTextDirection(_subtitle),
277279
text: subtitleSpan,
278280
overflow: TextOverflow.ellipsis,
279281
maxLines: maxLines,

‎lib/app/layouts/conversation_view/widgets/message/reply/reply_bubble.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class _ReplyBubbleState extends State<ReplyBubble> with ThemeHelpers {
9898
style: context.textTheme.labelLarge!.copyWith(color: context.theme.colorScheme.onSurface),
9999
maxLines: 2,
100100
overflow: TextOverflow.ellipsis,
101+
textDirection: getTextDirection(text),
101102
),
102103
),
103104
),
@@ -224,6 +225,7 @@ class _ReplyBubbleState extends State<ReplyBubble> with ThemeHelpers {
224225
builder: (context, snapshot) {
225226
if (snapshot.data != null) {
226227
return RichText(
228+
textDirection: getTextDirection(part.fullText),
227229
text: TextSpan(
228230
children: snapshot.data!,
229231
),

‎lib/app/layouts/conversation_view/widgets/message/send_animation.dart‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ class _SendAnimationState extends CustomState<SendAnimation, SendData, Conversat
336336
? const EdgeInsets.only(left: 3, right: 3)
337337
: EdgeInsets.zero,
338338
child: RichText(
339+
textDirection: getTextDirection(message!.fullText),
339340
text: TextSpan(
340341
children: buildMessageSpans(context,
341342
MessagePart(part: 0, text: message!.text, subject: message!.subject), message!,

‎lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ class _TextBubbleState extends State<TextBubble> with ThemeHelpers {
182182
return Transform.scale(scale: value1, alignment: Alignment.center, child: child);
183183
},
184184
child: RichText(
185+
textDirection: getTextDirection(part.fullText),
185186
text: TextSpan(
186187
children: snapshot.data!,
187188
),
@@ -194,6 +195,7 @@ class _TextBubbleState extends State<TextBubble> with ThemeHelpers {
194195
padding:
195196
message.fullText.length == 1 ? const EdgeInsets.only(left: 3, right: 3) : EdgeInsets.zero,
196197
child: RichText(
198+
textDirection: getTextDirection(part.fullText),
197199
text: TextSpan(
198200
children: snapshot.data!,
199201
),

‎lib/app/layouts/conversation_view/widgets/text_field/conversation_text_field.dart‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,11 @@ class ConversationTextFieldState extends CustomState<ConversationTextField, void
9999
String text = realController.text;
100100
TextSelection selection = realController.selection;
101101

102-
realController.text = text.substring(0, selection.start) + emoji + text.substring(selection.end);
103-
realController.selection = TextSelection.collapsed(offset: selection.start + emoji.length);
102+
// Grapheme-safe: snap to cluster boundaries so inserting an emoji next to
103+
// another emoji / ZWJ sequence never splits a surrogate pair (renders "?").
104+
final r = graphemeSafeReplace(text, selection.start, selection.end, emoji);
105+
realController.text = r.text;
106+
realController.selection = TextSelection.collapsed(offset: r.caret);
104107

105108
(controller.editing.lastOrNull?.controller.focusNode ?? controller.lastFocusedNode).requestFocus();
106109
});

0 commit comments

Comments
 (0)