Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/flutter/lib/src/rendering/editable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ class VerticalCaretMovementRun implements Iterator<TextPosition> {
final List<ui.LineMetrics> newLineMetrics = _editable._textPainter.computeLineMetrics();
// Use the implementation detail of the computeLineMetrics method to figure
// out if the current text layout has been invalidated.
if (!identical(newLineMetrics, _lineMetrics)) {
if (!listEquals(newLineMetrics, _lineMetrics)) {

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.

Could you explain this change?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

When textDirection: TextDirection.rtl is set for a TextField, pressing the down arrow key in the TextField triggers the following line first:

final List<ui.LineMetrics> newLineMetrics = _editable._textPainter.computeLineMetrics();

Let’s analyze this function:

List<ui.LineMetrics> computeLineMetrics() {
  ...
  return offset == Offset.zero
      ? rawMetrics
      : rawMetrics
          .map((ui.LineMetrics metrics) => _shiftLineMetrics(metrics, offset))
          .toList(growable: false);
}

When the TextField is in LTR mode, the condition offset == Offset.zero is true. However, when it is in RTL mode, the condition will not hold true (see the attached image).
As a result, rawMetrics is returned with modifications. The returned output is equivalent in value to rawMetrics.
Therefore, I replaced identical with listEquals, but I believe the correct solution is to fix the issue in the computeLineMetrics function itself.

image

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.

That makes sense to me. The identical is incorrect for RTL.

I wonder if the _TextPainterLayoutCacheWithOffset.lineMetrics getter should do the shifting and then assign the shifted line metrics to its _cachedLinesMetrics. That's a risky change though, so it should probably be done in a separate PR.

I think that @MrMontazer you should continue with your current approach, but create an issue with this information. Point to your listEquals and say that should be changed to identical along with the refactor I described above, for performance reasons.

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.

The current implementation of VerticalCaretMovementRun uses this hack to detect whether TextPainter recomputed its layout. So the use of identical here is intended.

IIRC the offset here has nothing to do with the writing direction of the text.

@MrMontazer MrMontazer Feb 13, 2025 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

These changes are related to #139201.
I agree that this is incorrect. I think I will revert it to the previous state and leave fixing this issue for another pull request

_isValid = false;
}
return _isValid;
Expand Down
8 changes: 7 additions & 1 deletion packages/flutter/lib/src/rendering/paragraph.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1567,8 +1567,14 @@ class _SelectableFragment
result = _handleSelectParagraph(selectParagraph.globalPosition);
}
case SelectionEventType.granularlyExtendSelection:
if (event is HorizontalGranularlyExtendSelectionEvent) {
event.textDirection = paragraph.textDirection;
}

final GranularlyExtendSelectionEvent granularlyExtendSelection =
event as GranularlyExtendSelectionEvent;
event is HorizontalGranularlyExtendSelectionEvent
? event.withTextDirection(paragraph.textDirection)

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.

Are we sure that this is the first place we can get the text direction from?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
  switch (event.type) {
    case SelectionEventType.startEdgeUpdate:
      didReceiveSelectionEventFor(selectable: selectable, forEnd: false);
      ensureChildUpdated(selectable);
    case SelectionEventType.endEdgeUpdate:
      didReceiveSelectionEventFor(selectable: selectable, forEnd: true);
      ensureChildUpdated(selectable);
    case SelectionEventType.clear:
      clearInternalSelectionStateForSelectable(selectable);
    case SelectionEventType.selectAll:
    case SelectionEventType.selectWord:
    case SelectionEventType.selectParagraph:
      break;
    case SelectionEventType.granularlyExtendSelection:
    case SelectionEventType.directionallyExtendSelection:
      didReceiveSelectionEventFor(selectable: selectable);
      ensureChildUpdated(selectable);
  }
  return super.dispatchSelectionEventToChild(selectable, event);
}

In selectable_region.dart line 2288, by detecting if selectable.widget.delegate is _SelectableTextContainerDelegate, we might be able to determine the TextDirection earlier.
However, it’s important to note that _SelectableTextContainerDelegate is defined in a different file.

: event as GranularlyExtendSelectionEvent;
result = _handleGranularlyExtendSelection(
granularlyExtendSelection.forward,
granularlyExtendSelection.isEnd,
Expand Down
33 changes: 33 additions & 0 deletions packages/flutter/lib/src/rendering/selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,39 @@ class GranularlyExtendSelectionEvent extends SelectionEvent {
final TextGranularity granularity;
}

/// Extends the start or end of the selection horizontally by a given [TextGranularity].
///
/// To handle this event, move the associated selection edge, as dictated by
/// [isEnd], according to the [granularity] and the [textDirection].
/// The [right] parameter determines the direction of the extension in the
/// horizontal axis, which is then translated to a forward or backward
/// extension based on the [textDirection].
class HorizontalGranularlyExtendSelectionEvent extends GranularlyExtendSelectionEvent {
/// Creates a [HorizontalGranularlyExtendSelectionEvent].
HorizontalGranularlyExtendSelectionEvent({
required this.right,
required super.isEnd,
required super.granularity,
this.textDirection = TextDirection.ltr,
}) : super(forward: textDirection == TextDirection.rtl ? !right : right);

/// Whether to extend the selection to the right.
bool right;

/// The text direction for which the selection extend.
TextDirection textDirection;

/// Creates a new instance of [HorizontalGranularlyExtendSelectionEvent] with the
/// specified [textDirection].
HorizontalGranularlyExtendSelectionEvent withTextDirection(TextDirection textDirection) =>
HorizontalGranularlyExtendSelectionEvent(
right: right,
textDirection: textDirection,
isEnd: isEnd,
granularity: granularity,
);
}

/// The direction to extend a selection.
///
/// The [DirectionallyExtendSelectionEvent] uses this enum to describe how
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,12 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
},

// Arrow: Move selection.
const SingleActivator(LogicalKeyboardKey.arrowLeft): const ExtendSelectionByCharacterIntent(
forward: false,
collapseSelection: true,
),
const SingleActivator(LogicalKeyboardKey.arrowRight): const ExtendSelectionByCharacterIntent(
forward: true,
collapseSelection: true,
),
const SingleActivator(
LogicalKeyboardKey.arrowLeft,
): const HorizontalExtendSelectionByCharacterIntent(right: false, collapseSelection: true),
const SingleActivator(
LogicalKeyboardKey.arrowRight,
): const HorizontalExtendSelectionByCharacterIntent(right: true, collapseSelection: true),
const SingleActivator(
LogicalKeyboardKey.arrowUp,
): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: true),
Expand All @@ -220,11 +218,11 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
const SingleActivator(
LogicalKeyboardKey.arrowLeft,
shift: true,
): const ExtendSelectionByCharacterIntent(forward: false, collapseSelection: false),
): const HorizontalExtendSelectionByCharacterIntent(right: false, collapseSelection: false),
const SingleActivator(
LogicalKeyboardKey.arrowRight,
shift: true,
): const ExtendSelectionByCharacterIntent(forward: true, collapseSelection: false),
): const HorizontalExtendSelectionByCharacterIntent(right: true, collapseSelection: false),
const SingleActivator(
LogicalKeyboardKey.arrowUp,
shift: true,
Expand Down Expand Up @@ -601,14 +599,12 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
): const DeleteToLineBreakIntent(forward: true),
},

const SingleActivator(LogicalKeyboardKey.arrowLeft): const ExtendSelectionByCharacterIntent(
forward: false,
collapseSelection: true,
),
const SingleActivator(LogicalKeyboardKey.arrowRight): const ExtendSelectionByCharacterIntent(
forward: true,
collapseSelection: true,
),
const SingleActivator(
LogicalKeyboardKey.arrowLeft,
): const HorizontalExtendSelectionByCharacterIntent(right: false, collapseSelection: true),
const SingleActivator(
LogicalKeyboardKey.arrowRight,
): const HorizontalExtendSelectionByCharacterIntent(right: true, collapseSelection: true),
const SingleActivator(
LogicalKeyboardKey.arrowUp,
): const ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: true),
Expand All @@ -620,11 +616,11 @@ class DefaultTextEditingShortcuts extends StatelessWidget {
const SingleActivator(
LogicalKeyboardKey.arrowLeft,
shift: true,
): const ExtendSelectionByCharacterIntent(forward: false, collapseSelection: false),
): const HorizontalExtendSelectionByCharacterIntent(right: false, collapseSelection: false),
const SingleActivator(
LogicalKeyboardKey.arrowRight,
shift: true,
): const ExtendSelectionByCharacterIntent(forward: true, collapseSelection: false),
): const HorizontalExtendSelectionByCharacterIntent(right: true, collapseSelection: false),
const SingleActivator(
LogicalKeyboardKey.arrowUp,
shift: true,
Expand Down Expand Up @@ -1091,8 +1087,8 @@ Intent? intentForMacOSSelector(String selectorName) {
'deleteWordForward:': DeleteToNextWordBoundaryIntent(forward: true),
'deleteToEndOfLine:': DeleteToLineBreakIntent(forward: true),

'moveLeft:': ExtendSelectionByCharacterIntent(forward: false, collapseSelection: true),
'moveRight:': ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true),
'moveLeft:': HorizontalExtendSelectionByCharacterIntent(right: false, collapseSelection: true),
'moveRight:': HorizontalExtendSelectionByCharacterIntent(right: true, collapseSelection: true),
'moveForward:': ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true),
'moveBackward:': ExtendSelectionByCharacterIntent(forward: false, collapseSelection: true),

Expand All @@ -1105,12 +1101,12 @@ Intent? intentForMacOSSelector(String selectorName) {
collapseSelection: true,
),

'moveLeftAndModifySelection:': ExtendSelectionByCharacterIntent(
forward: false,
'moveLeftAndModifySelection:': HorizontalExtendSelectionByCharacterIntent(
right: false,
collapseSelection: false,
),
'moveRightAndModifySelection:': ExtendSelectionByCharacterIntent(
forward: true,
'moveRightAndModifySelection:': HorizontalExtendSelectionByCharacterIntent(
right: true,
collapseSelection: false,
),
'moveUpAndModifySelection:': ExtendSelectionVerticallyToAdjacentLineIntent(
Expand Down
28 changes: 28 additions & 0 deletions packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5401,6 +5401,15 @@ class EditableTextState extends State<EditableText>
ignoreNonCollapsedSelection: false,
),
),
HorizontalExtendSelectionByCharacterIntent: _makeOverridable(
_UpdateHorizontalTextSelectionAction(
this,
_characterBoundary,
_moveBeyondTextBoundary,
ignoreNonCollapsedSelection: false,
textDirection: widget.textDirection ?? Directionality.of(context),
),
),
ExtendSelectionByPageIntent: _makeOverridable(
CallbackAction<ExtendSelectionByPageIntent>(onInvoke: _extendSelectionByPage),
),
Expand Down Expand Up @@ -6337,6 +6346,25 @@ class _UpdateTextSelectionAction<T extends DirectionalCaretMovementIntent>
bool get isActionEnabled => state._value.selection.isValid;
}

class _UpdateHorizontalTextSelectionAction extends _UpdateTextSelectionAction<HorizontalExtendSelectionByCharacterIntent>{
_UpdateHorizontalTextSelectionAction(
super.state,
super.getTextBoundary,
super.applyTextBoundary, {
required this.textDirection,
required super.ignoreNonCollapsedSelection,
// super.isExpand,
// super.extentAtIndex,
});

final TextDirection textDirection;

@override
Object? invoke(HorizontalExtendSelectionByCharacterIntent intent, [BuildContext? context]) {
return super.invoke(intent.withTextDirection(textDirection), context);
}
}

class _UpdateTextSelectionVerticallyAction<T extends DirectionalCaretMovementIntent>
extends ContextAction<T> {
_UpdateTextSelectionVerticallyAction(this.state);
Expand Down
42 changes: 42 additions & 0 deletions packages/flutter/lib/src/widgets/selectable_region.dart
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,9 @@ class SelectableRegionState extends State<SelectableRegion>
granularity: TextGranularity.character,
),
),
HorizontalExtendSelectionByCharacterIntent: _makeOverridable(
_GranularlyHorizontalExtendCaretSelectionAction(this, granularity: TextGranularity.character),
),
ExtendSelectionToNextWordBoundaryIntent: _makeOverridable(
_GranularlyExtendCaretSelectionAction<ExtendSelectionToNextWordBoundaryIntent>(
this,
Expand Down Expand Up @@ -1640,6 +1643,23 @@ class SelectableRegionState extends State<SelectableRegion>
_finalizeSelectableRegionStatus();
}

void _granularlyHorizontalExtendSelection(TextGranularity granularity, bool right) {
_directionalHorizontalBaseline = null;
if (!_selectionDelegate.value.hasSelection) {
return;
}
_selectable?.dispatchSelectionEvent(
HorizontalGranularlyExtendSelectionEvent(

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.

So we don't know the text direction at this point because this is the whole SelectableRegionState, which internally could have different TextDirection widgets, such as with my example app (in #160516 (review)).

right: right,
isEnd: _determineIsAdjustingSelectionEnd(right),
granularity: granularity,
),
);
_updateSelectedContentIfNeeded();
_selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing;
_finalizeSelectableRegionStatus();
}

double? _directionalHorizontalBaseline;

void _directionallyExtendSelection(bool forward) {
Expand Down Expand Up @@ -2027,6 +2047,20 @@ class _GranularlyExtendCaretSelectionAction<T extends DirectionalCaretMovementIn
}
}

class _GranularlyHorizontalExtendCaretSelectionAction
extends _GranularlyExtendCaretSelectionAction<HorizontalExtendSelectionByCharacterIntent> {
_GranularlyHorizontalExtendCaretSelectionAction(super.state, {required super.granularity});

@override
void invokeAction(HorizontalExtendSelectionByCharacterIntent intent, [BuildContext? context]) {
if (intent.collapseSelection) {
// Selectable region never collapses selection.
return;
}
state._granularlyHorizontalExtendSelection(granularity, intent.right);
}
}

class _DirectionallyExtendCaretSelectionAction<T extends DirectionalCaretMovementIntent>
extends _NonOverrideAction<T> {
_DirectionallyExtendCaretSelectionAction(this.state);
Expand Down Expand Up @@ -3032,6 +3066,14 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai
}
int targetIndex = event.isEnd ? currentSelectionEndIndex : currentSelectionStartIndex;
SelectionResult result = dispatchSelectionEventToChild(selectables[targetIndex], event);

if (event is HorizontalGranularlyExtendSelectionEvent) {
// In HorizontalGranularlyExtendSelectionEvent, the "forward" property may not be valid
// until the TextDirection is determined. Therefore, we recreate the event with the
// proper TextDirection to ensure it reflects the correct directionality for the selection extension.
event = event.withTextDirection(event.textDirection);
Comment on lines +3071 to +3074

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.

The idea that we have events that are not fully accurate until being updated like this seems kind of error-prone. I'm still thinking through what the better way might be though.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

One possible solution could be to handle horizontal events directly in widgets that need to support them, such as Text, instead of translating right to forward. This would likely require significant changes.

}

if (event.forward) {
assert(result != SelectionResult.previous);
while (targetIndex < selectables.length - 1 && result == SelectionResult.next) {
Expand Down
32 changes: 32 additions & 0 deletions packages/flutter/lib/src/widgets/text_editing_intents.dart
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,38 @@ class ExtendSelectionByCharacterIntent extends DirectionalCaretMovementIntent {
: super(forward, collapseSelection);
}

/// Extends, or moves the current selection from the current
/// [TextSelection.extent] position to the left or the right character
/// boundary.
class HorizontalExtendSelectionByCharacterIntent extends ExtendSelectionByCharacterIntent {
/// Creates an [HorizontalExtendSelectionByCharacterIntent].
const HorizontalExtendSelectionByCharacterIntent({
required this.right,
required super.collapseSelection,
TextDirection textDirection = TextDirection.ltr,

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.

The creator of the intent is likely going to have no information about who the receiving text field is going to be, so how does it know what text direction to use?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Actually, this field is assigned a value once the direction is specified. Check this.
However, I know that the way this behavior is implemented is not ideal, so your feedback could be helpful in improving the implementation of this issue.

@LongCatIsLooong LongCatIsLooong Feb 14, 2025 •

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.

this field is assigned a value once the direction is specified

I think that's a bit problematic as the widget that emits the intent may not know who the receiving text field is going to be (as a result it has not way to determine the current writing direction since different text fields can have different writing directions). IOW, when you're constructing the intent, there's no way to determine the correct TextDirection.

Also the intent should be (and it is) immutable.

}) : super(forward: textDirection == TextDirection.rtl ? !right : right);

/// Whether the input field, if applicable, should perform the text editing
/// operation from the current caret location towards the right direction.
///
/// Unless otherwise specified by the recipient of this intent, this parameter
/// uses the visual order of characters on the screen to determine the
/// direction, and is not affected by the logical order of the text.

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.

nit: let's make it simpler. Maybe just whether the receiving text field should move the caret to the left / right by one character?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I should mention that there are many shortcuts for the TextField.
The simplest shortcuts are the left and right arrow keys.
However, combination keys that include the left or right arrow, such as Shift + Left Arrow, also exhibit reversed behavior in RTL (right-to-left) mode.
Do you still think the same way?

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.

Sorry I wasn't clear, I meant maybe the documentation can be a bit simpler.

final bool right;

/// Creates a new instance of [HorizontalExtendSelectionByCharacterIntent] with the
/// specified [textDirection].
///
/// The new instance retains the current values of [right] and [collapseSelection],
/// allowing dynamic adjustment of the intent based on the given text direction.
HorizontalExtendSelectionByCharacterIntent withTextDirection(TextDirection textDirection) =>
HorizontalExtendSelectionByCharacterIntent(
right: right,
textDirection: textDirection,
collapseSelection: collapseSelection,
);
}

/// Extends, or moves the current selection from the current
/// [TextSelection.extent] position to the previous or the next word
/// boundary.
Expand Down
54 changes: 54 additions & 0 deletions packages/flutter/test/material/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17506,6 +17506,60 @@ void main() {
},
variant: TargetPlatformVariant.all(),
);

testWidgets(
'The behavior of the left arrow and right arrow in an RTL text field should be reversed.',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController();

await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TextField(
controller: controller,
textAlign: TextAlign.right,
textDirection: TextDirection.rtl,
),
),
),
);

await tester.showKeyboard(find.byType(TextField));

const String testValue = 'RTL-test';
tester.testTextInput.updateEditingValue(
const TextEditingValue(text: testValue, selection: TextSelection.collapsed(offset: 3)),
);
await tester.pump();

await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();

expect(controller.selection.base.offset, 4);

await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();

expect(controller.selection.base.offset, 3);

// Shift
await tester.sendKeyDownEvent(LogicalKeyboardKey.shift);

await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
await tester.pump();

expect(controller.selection.base.offset, 3);
expect(controller.selection.extent.offset, 4);

await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();

expect(controller.selection.base.offset, 3);
expect(controller.selection.extent.offset, 3);

controller.dispose();
},
);
}

/// A Simple widget for testing the obscure text.
Expand Down
Loading